panda_pal 5.16.20 → 5.17.0
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/app/models/panda_pal/platform/canvas.rb +1 -1
- data/app/models/panda_pal/platform.rb +2 -0
- data/app/models/panda_pal/session.rb +63 -9
- data/config/initializers/apartment.rb +6 -0
- data/lib/panda_pal/engine.rb +6 -6
- data/lib/panda_pal/helpers/controller_helper.rb +1 -1
- data/lib/panda_pal/helpers/secure_headers.rb +1 -1
- data/lib/panda_pal/helpers/session_replacement.rb +1 -1
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +2 -2
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +110 -0
- data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +78 -0
- data/spec/core/apartment_multidb_spec.rb +29 -0
- data/spec/factories/panda_pal_sessions.rb +1 -2
- data/spec/internal/app/models/account.rb +7 -0
- data/spec/internal/app/models/admin.rb +6 -0
- data/spec/internal/db/schema.rb +24 -0
- data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +83 -0
- data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +77 -0
- data/spec/models/panda_pal/platform_spec.rb +152 -0
- data/spec/models/panda_pal/session_account_roles_spec.rb +182 -0
- data/spec/models/panda_pal/session_spec.rb +146 -0
- data/spec/spec_helper.rb +7 -0
- metadata +23 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa9bee5cce120a4bf340d69ebd142e6f11f8d65bb0b4d850bb781662469ba84e
|
|
4
|
+
data.tar.gz: b1040ec6ecbd33c7ff5fe0eef396a2e5d1764ec893a1c439b5cd8de0ec7d726c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03ffb7a4bf0fe654ad310e8a8c34e69b1297ed02ab3a38a989d0712a630e2c41ca52cb76333ef0d400acabd3688a5c2d33621ab24d59027698e444581260403d
|
|
7
|
+
data.tar.gz: 522d2c9847b0225228d808a053a0eb278d872750883758bc60ccb87128162645fe989d7fa1e14f7fb9384fea69e7fae540dcaf02d4927f1691dc21c1f53223fe
|
|
@@ -152,7 +152,7 @@ module PandaPal
|
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
def sign_value(data)
|
|
155
|
-
OpenSSL::HMAC.
|
|
155
|
+
Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), signing_key, data))
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
def validate_signature(data, signature)
|
|
@@ -239,24 +239,78 @@ module PandaPal
|
|
|
239
239
|
labels.is_a?(String) ? labels.split(',') : []
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
-
# Retrieve the User's Role Labels in the specified Account
|
|
243
|
-
|
|
242
|
+
# Retrieve the User's Role Labels in the specified Account.
|
|
243
|
+
#
|
|
244
|
+
# +account+ accepts an Account-like object (anything responding to
|
|
245
|
+
# +canvas_id+), a Canvas Account id, or one of:
|
|
246
|
+
#
|
|
247
|
+
# [<tt>'self'</tt>] the Account this Tool is installed on (the default).
|
|
248
|
+
# [<tt>:root</tt>] a synonym for <tt>'self'</tt>. In an LTI context the
|
|
249
|
+
# installed Account IS the root of everything the Tool can
|
|
250
|
+
# see, and that — deliberately — need not be Canvas's own
|
|
251
|
+
# root Account: a Tool installed on a sub-account has no
|
|
252
|
+
# business reasoning about Accounts above its install.
|
|
253
|
+
#
|
|
254
|
+
# With <tt>inherited: true</tt>, roles held on any ANCESTOR of +account+ are
|
|
255
|
+
# included too, because a Canvas Account Admin administers their Account and
|
|
256
|
+
# everything beneath it — so an Admin above +account+ genuinely holds that
|
|
257
|
+
# role over +account+. Off by default: a caller asking "which roles are
|
|
258
|
+
# granted AT this Account" would not expect inherited ones, so opting in is
|
|
259
|
+
# explicit, as CanvasSync's +canvas_account_admin?+ does.
|
|
260
|
+
def canvas_account_role_labels(account = 'self', inherited: false)
|
|
244
261
|
account = 'self' if account.to_s == "root"
|
|
245
262
|
account = account.canvas_id if account.respond_to?(:canvas_id)
|
|
246
263
|
|
|
247
264
|
if defined?(::Admin) && ::Admin < ::ActiveRecord::Base
|
|
248
|
-
account =
|
|
249
|
-
|
|
265
|
+
account = panda_pal_organization.canvas_account_id if account == 'self'
|
|
266
|
+
accounts = inherited ? canvas_account_ancestry(account) : [account]
|
|
267
|
+
adm_query = ::Admin.where(canvas_account_id: accounts, workflow_state: "active", canvas_user_id: canvas_user_id)
|
|
250
268
|
adm_query.pluck(:role_name)
|
|
251
269
|
else
|
|
252
|
-
Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, canvas_user_id], expires_in: 1.hour) do
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
270
|
+
Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, inherited, canvas_user_id], expires_in: 1.hour) do
|
|
271
|
+
accounts = inherited ? canvas_account_ancestry(account) : [account]
|
|
272
|
+
accounts.flat_map { |acct|
|
|
273
|
+
admin_entries = canvas_sync_client.account_admins(acct, user_id: [canvas_user_id])
|
|
274
|
+
admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
|
|
275
|
+
admin_entries.map{|ent| ent[:role] }
|
|
276
|
+
}.uniq
|
|
256
277
|
end
|
|
257
278
|
end
|
|
258
279
|
end
|
|
259
280
|
|
|
281
|
+
# +account+ plus every Account above it, nearest first.
|
|
282
|
+
#
|
|
283
|
+
# Reads the local Account mirror when the host app has one — no API traffic,
|
|
284
|
+
# and it works whether or not that model uses the +ancestry+ gem, since only
|
|
285
|
+
# +canvas_parent_account_id+ is needed. Falls back to walking Canvas.
|
|
286
|
+
#
|
|
287
|
+
# Traversal is topological and deliberately does NOT skip deleted Accounts:
|
|
288
|
+
# dropping one would silently truncate the chain and deny an Admin above it.
|
|
289
|
+
# Whether a role still counts is decided by the Admin record's own
|
|
290
|
+
# +workflow_state+, not by the shape of the tree.
|
|
291
|
+
def canvas_account_ancestry(account)
|
|
292
|
+
ids = [account]
|
|
293
|
+
seen = Set.new([account.to_s])
|
|
294
|
+
|
|
295
|
+
parent_of =
|
|
296
|
+
if defined?(::Account) && ::Account < ::ActiveRecord::Base
|
|
297
|
+
->(id) { ::Account.find_by(canvas_id: id)&.canvas_parent_account_id }
|
|
298
|
+
else
|
|
299
|
+
->(id) { canvas_sync_client.account(id)[:parent_account_id] }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
current = account
|
|
303
|
+
while (parent = parent_of.call(current)).present?
|
|
304
|
+
break if seen.include?(parent.to_s) # cycle guard — never trust remote topology
|
|
305
|
+
|
|
306
|
+
ids << parent
|
|
307
|
+
seen << parent.to_s
|
|
308
|
+
current = parent
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
ids
|
|
312
|
+
end
|
|
313
|
+
|
|
260
314
|
def lti_roles
|
|
261
315
|
@lti_roles ||= RoleStore.new(launch_params["https://purl.imsglobal.org/spec/lti/claim/roles"] || launch_params['ext_roles'] || '')
|
|
262
316
|
end
|
|
@@ -130,6 +130,12 @@ module Apartment
|
|
|
130
130
|
raise ActiveRecord::StatementInvalid, "Could not find schema for tenant #{tenant} (#{tenant_schemas.inspect})" unless schema_exists?(tenant_schemas)
|
|
131
131
|
|
|
132
132
|
Apartment.connection.schema_search_path = full_search_path
|
|
133
|
+
rescue ActiveRecord::ConnectionNotEstablished
|
|
134
|
+
# Connection-acquisition failures (e.g. pool checkout timeouts) are unrelated to schema/search-path
|
|
135
|
+
# resolution. Let them propagate as themselves instead of masking them as Apartment::TenantNotFound,
|
|
136
|
+
# which sends whoever's debugging the error looking for a schema/tenant problem that doesn't exist.
|
|
137
|
+
@current = current_tenant
|
|
138
|
+
raise
|
|
133
139
|
rescue *rescuable_exceptions => e
|
|
134
140
|
@current = current_tenant
|
|
135
141
|
raise_schema_connect_to_new(tenant, e)
|
data/lib/panda_pal/engine.rb
CHANGED
|
@@ -94,18 +94,18 @@ module PandaPal
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def _panda_pal_console_env
|
|
97
|
-
if Rails.env.
|
|
98
|
-
|
|
97
|
+
if Rails.env.development?
|
|
98
|
+
PandaPal::ConsoleHelpers.cyan("dev")
|
|
99
|
+
elsif Rails.env.test?
|
|
100
|
+
PandaPal::ConsoleHelpers.cyan("test")
|
|
101
|
+
else
|
|
102
|
+
env = ENV["SENTRY_CURRENT_ENV"].presence || Rails.env.upcase || "PROD"
|
|
99
103
|
|
|
100
104
|
if env.downcase.include?("prod")
|
|
101
105
|
PandaPal::ConsoleHelpers.red(env)
|
|
102
106
|
else
|
|
103
107
|
PandaPal::ConsoleHelpers.cyan(env)
|
|
104
108
|
end
|
|
105
|
-
elsif Rails.env.development?
|
|
106
|
-
PandaPal::ConsoleHelpers.cyan("dev")
|
|
107
|
-
elsif Rails.env.test?
|
|
108
|
-
PandaPal::ConsoleHelpers.cyan("test")
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -5,7 +5,7 @@ module PandaPal
|
|
|
5
5
|
# The default cookie headers aren't compatable with PandaPal cookies currenntly
|
|
6
6
|
config.cookies = { samesite: { none: true } }
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
unless Rails.env.development? || Rails.env.test?
|
|
9
9
|
config.cookies[:secure] = true
|
|
10
10
|
end
|
|
11
11
|
|
|
@@ -57,7 +57,7 @@ module PandaPal::Helpers
|
|
|
57
57
|
current_panda_session
|
|
58
58
|
|
|
59
59
|
if !@current_session && create_missing
|
|
60
|
-
Rails.logger.warn("current_session(create_missing: true) is deprecated. Use start_panda_session! instead.")
|
|
60
|
+
Rails.logger.warn("current_session(create_missing: true) is deprecated. Use start_panda_session! instead.") if Rails.env.development? || Rails.env.test?
|
|
61
61
|
start_panda_session!
|
|
62
62
|
end
|
|
63
63
|
|
data/lib/panda_pal/version.rb
CHANGED
data/panda_pal.gemspec
CHANGED
|
@@ -23,8 +23,8 @@ Gem::Specification.new do |s|
|
|
|
23
23
|
s.add_dependency "rails", ">= 4.2"
|
|
24
24
|
s.add_dependency 'ros-apartment', '~> 3.0'
|
|
25
25
|
s.add_dependency 'ims-lti', '~> 1.2.4'
|
|
26
|
-
s.add_dependency 'browser', '
|
|
27
|
-
s.add_dependency 'attr_encrypted', '~> 4.
|
|
26
|
+
s.add_dependency 'browser', '~> 5.3'
|
|
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
30
|
s.add_dependency 'httparty'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
|
|
4
|
+
let(:organization) { create(:panda_pal_organization) }
|
|
5
|
+
let(:launch_url) { panda_pal.v1p0_launch_url(host: 'www.example.com') }
|
|
6
|
+
|
|
7
|
+
# Signs a full LTI v1p0 launch params hash the same way an LMS (Tool Consumer)
|
|
8
|
+
# would, using the platform-side half of the very same `ims-lti` gem that
|
|
9
|
+
# PandaPal uses on the tool-provider side to validate incoming launches.
|
|
10
|
+
def signed_launch_params(secret:, timestamp: Time.now.to_i, launch_type: 'panda_pal')
|
|
11
|
+
consumer = IMS::LTI::ToolConsumer.new(organization.key, secret, {
|
|
12
|
+
'resource_link_id' => 'test-resource-link-id',
|
|
13
|
+
})
|
|
14
|
+
consumer.launch_url = launch_url
|
|
15
|
+
consumer.timestamp = timestamp
|
|
16
|
+
# `launch_type` isn't a spec'd LTI param, but the controller reads it from
|
|
17
|
+
# params, so it must be included in the signed payload or the signature
|
|
18
|
+
# PandaPal recomputes on the real request body won't match.
|
|
19
|
+
consumer.set_non_spec_param('launch_type', launch_type)
|
|
20
|
+
consumer.generate_launch_data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
after do
|
|
24
|
+
# `validate_v1p0_launch` permanently switches the Apartment tenant to the
|
|
25
|
+
# organization's schema on a successful launch (PandaPal::Organization
|
|
26
|
+
# #switch_tenant with no block => Apartment::Tenant.switch!). Reset it so
|
|
27
|
+
# this doesn't leak into other spec files run later in the suite.
|
|
28
|
+
Apartment::Tenant.reset
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe 'POST /v1p0/launch' do
|
|
32
|
+
context 'with a validly-signed launch' do
|
|
33
|
+
it 'passes signature validation, starts a session, and redirects instead of rendering the 401 body' do
|
|
34
|
+
params = signed_launch_params(secret: organization.secret)
|
|
35
|
+
|
|
36
|
+
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
37
|
+
|
|
38
|
+
expect(response).not_to have_http_status(:unauthorized)
|
|
39
|
+
expect(response.body).not_to include('Failed to validate LTI v1p0 launch')
|
|
40
|
+
expect(response).to have_http_status(:found)
|
|
41
|
+
expect(response.headers['Location']).to include('/panda_pal')
|
|
42
|
+
|
|
43
|
+
# The session record is created after PandaPal switches Apartment tenants
|
|
44
|
+
# to the organization's own schema, so look it up there.
|
|
45
|
+
session_count = organization.switch_tenant { PandaPal::Session.count }
|
|
46
|
+
expect(session_count).to eq(1)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'with an invalid signature (wrong secret used to sign)' do
|
|
51
|
+
it 'renders 401 with the failure body' do
|
|
52
|
+
params = signed_launch_params(secret: 'not-the-real-secret')
|
|
53
|
+
|
|
54
|
+
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
55
|
+
|
|
56
|
+
expect(response).to have_http_status(:unauthorized)
|
|
57
|
+
expect(response.body).to eq('Failed to validate LTI v1p0 launch')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
context 'with a tampered parameter after signing' do
|
|
62
|
+
it 'renders 401 with the failure body' do
|
|
63
|
+
params = signed_launch_params(secret: organization.secret)
|
|
64
|
+
params['resource_link_id'] = 'a-different-resource-link-id'
|
|
65
|
+
|
|
66
|
+
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
67
|
+
|
|
68
|
+
expect(response).to have_http_status(:unauthorized)
|
|
69
|
+
expect(response.body).to eq('Failed to validate LTI v1p0 launch')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
context 'with a stale oauth_timestamp' do
|
|
74
|
+
it 'renders 401 without ever consulting IMS::LTI::ToolProvider' do
|
|
75
|
+
stale_timestamp = 400.seconds.ago.to_i
|
|
76
|
+
params = signed_launch_params(secret: organization.secret, timestamp: stale_timestamp)
|
|
77
|
+
|
|
78
|
+
expect(IMS::LTI::ToolProvider).not_to receive(:new)
|
|
79
|
+
|
|
80
|
+
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
81
|
+
|
|
82
|
+
expect(response).to have_http_status(:unauthorized)
|
|
83
|
+
expect(response.body).to eq('Failed to validate LTI v1p0 launch')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe 'safari_override (browser gem regression coverage)' do
|
|
89
|
+
let(:safari_user_agent) do
|
|
90
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
|
91
|
+
end
|
|
92
|
+
let(:chrome_user_agent) do
|
|
93
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# safari_override is the very first thing validate_launch! does, before any
|
|
97
|
+
# OAuth/org checks, so an otherwise-invalid launch is sufficient to exercise it.
|
|
98
|
+
it 'applies the secure_headers safari override when the User-Agent is Safari' do
|
|
99
|
+
expect_any_instance_of(PandaPal::LtiV1P0Controller).to receive(:use_secure_headers_override).with(:safari_override)
|
|
100
|
+
|
|
101
|
+
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => safari_user_agent }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'does not apply the override for a non-Safari User-Agent (Chrome)' do
|
|
105
|
+
expect_any_instance_of(PandaPal::LtiV1P0Controller).not_to receive(:use_secure_headers_override)
|
|
106
|
+
|
|
107
|
+
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => chrome_user_agent }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
end
|
|
@@ -68,4 +68,33 @@ RSpec.describe PandaPal::Organization, type: :model do
|
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
context "tenant switch error wrapping" do
|
|
73
|
+
around do |example|
|
|
74
|
+
original = Apartment.with_multi_server_setup
|
|
75
|
+
Apartment.configure { |c| c.with_multi_server_setup = true }
|
|
76
|
+
Apartment::Tenant.reload!
|
|
77
|
+
example.run
|
|
78
|
+
ensure
|
|
79
|
+
Apartment.configure { |c| c.with_multi_server_setup = original }
|
|
80
|
+
Apartment::Tenant.reload!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "does not mask a connection pool timeout as Apartment::TenantNotFound (PFS-31252)" do
|
|
84
|
+
adapter = Apartment::Tenant.adapter
|
|
85
|
+
previous_tenant = adapter.current
|
|
86
|
+
|
|
87
|
+
allow(ActiveRecord::Base).to receive(:connection) do
|
|
88
|
+
# Un-stub before raising: ActiveRecord::Base.connection is inherited by
|
|
89
|
+
# ros-apartment's SeparateDbConnectionHandler (used for schema create/drop),
|
|
90
|
+
# so leaving the stub in place past this one intended failure would misroute
|
|
91
|
+
# unrelated connection lookups made elsewhere.
|
|
92
|
+
allow(ActiveRecord::Base).to receive(:connection).and_call_original
|
|
93
|
+
raise ActiveRecord::ConnectionTimeoutError, "could not obtain a connection from the pool within 5.000 seconds"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
expect { adapter.connect_to_new("alt:some_org") }.to raise_error(ActiveRecord::ConnectionTimeoutError)
|
|
97
|
+
expect(adapter.current).to eq(previous_tenant)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
71
100
|
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal stand-in for the CanvasSync-generated Account. Present so that
|
|
4
|
+
# Session#canvas_account_ancestry exercises its local-mirror branch (the one
|
|
5
|
+
# host apps actually hit) rather than the Canvas API fallback.
|
|
6
|
+
class Account < ActiveRecord::Base
|
|
7
|
+
end
|
data/spec/internal/db/schema.rb
CHANGED
|
@@ -3,4 +3,28 @@
|
|
|
3
3
|
ActiveRecord::Schema.define do
|
|
4
4
|
# Set up any tables you need to exist for your test suite that don't belong
|
|
5
5
|
# in migrations.
|
|
6
|
+
|
|
7
|
+
# Stand-ins for the CanvasSync-generated, tenant-scoped models that
|
|
8
|
+
# Session#canvas_account_role_labels duck-types on. Only the columns it
|
|
9
|
+
# actually reads are declared. `accounts` deliberately has NO `ancestry`
|
|
10
|
+
# column: that gem is optional in host apps, so the traversal has to work
|
|
11
|
+
# from `canvas_parent_account_id` alone.
|
|
12
|
+
create_table :accounts, force: true do |t|
|
|
13
|
+
t.bigint :canvas_id, null: false
|
|
14
|
+
t.bigint :canvas_parent_account_id
|
|
15
|
+
t.string :name
|
|
16
|
+
t.string :workflow_state, default: "active"
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
add_index :accounts, :canvas_id, unique: true
|
|
20
|
+
|
|
21
|
+
create_table :admins, force: true do |t|
|
|
22
|
+
t.bigint :canvas_id
|
|
23
|
+
t.bigint :canvas_user_id, null: false
|
|
24
|
+
t.bigint :canvas_account_id, null: false
|
|
25
|
+
t.string :role_name
|
|
26
|
+
t.string :workflow_state, default: "active"
|
|
27
|
+
t.timestamps
|
|
28
|
+
end
|
|
29
|
+
add_index :admins, [ :canvas_user_id, :canvas_account_id ]
|
|
6
30
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module PandaPal::Jobs
|
|
4
|
+
RSpec.describe GradePassbackJob, type: :job do
|
|
5
|
+
let(:organization) { create(:panda_pal_organization) }
|
|
6
|
+
let(:job) { described_class.new }
|
|
7
|
+
|
|
8
|
+
describe '#perform' do
|
|
9
|
+
context 'with missing required params' do
|
|
10
|
+
it 'raises MissingGradePassbackParams when passback_guid is missing' do
|
|
11
|
+
expect(IMS::LTI::ToolProvider).not_to receive(:new)
|
|
12
|
+
|
|
13
|
+
expect {
|
|
14
|
+
job.perform(organization, { passback_url: 'http://example.com/passback', score: 0.9 })
|
|
15
|
+
}.to raise_error(PandaPal::Jobs::MissingGradePassbackParams)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'raises MissingGradePassbackParams when passback_url is missing' do
|
|
19
|
+
expect(IMS::LTI::ToolProvider).not_to receive(:new)
|
|
20
|
+
|
|
21
|
+
expect {
|
|
22
|
+
job.perform(organization, { passback_guid: 'guid-123', score: 0.9 })
|
|
23
|
+
}.to raise_error(PandaPal::Jobs::MissingGradePassbackParams)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'raises MissingGradePassbackParams when both score and total_score are missing' do
|
|
27
|
+
expect(IMS::LTI::ToolProvider).not_to receive(:new)
|
|
28
|
+
|
|
29
|
+
expect {
|
|
30
|
+
job.perform(organization, { passback_guid: 'guid-123', passback_url: 'http://example.com/passback' })
|
|
31
|
+
}.to raise_error(PandaPal::Jobs::MissingGradePassbackParams)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'raises MissingGradePassbackParams when opts is entirely empty' do
|
|
35
|
+
expect(IMS::LTI::ToolProvider).not_to receive(:new)
|
|
36
|
+
|
|
37
|
+
expect {
|
|
38
|
+
job.perform(organization, {})
|
|
39
|
+
}.to raise_error(PandaPal::Jobs::MissingGradePassbackParams)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context 'with valid options' do
|
|
44
|
+
let(:opts) { { passback_guid: 'guid-123', passback_url: 'http://example.com/passback', score: 0.9 } }
|
|
45
|
+
let(:tool_provider_double) { double('IMS::LTI::ToolProvider') }
|
|
46
|
+
|
|
47
|
+
before do
|
|
48
|
+
allow(IMS::LTI::ToolProvider).to receive(:new)
|
|
49
|
+
.with(organization.key, organization.secret, hash_including(
|
|
50
|
+
'lis_result_sourcedid' => opts[:passback_guid],
|
|
51
|
+
'lis_outcome_service_url' => opts[:passback_url]
|
|
52
|
+
))
|
|
53
|
+
.and_return(tool_provider_double)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'posts to the LMS and does not raise when the response is successful' do
|
|
57
|
+
success_result = double('OutcomeResponse', success?: true)
|
|
58
|
+
expect(tool_provider_double).to receive(:post_extended_replace_result!).with(opts).and_return(success_result)
|
|
59
|
+
|
|
60
|
+
expect {
|
|
61
|
+
job.perform(organization, opts)
|
|
62
|
+
}.not_to raise_error
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'raises GradePassbackFailure when the tool provider reports failure' do
|
|
66
|
+
failure_result = double(
|
|
67
|
+
'OutcomeResponse',
|
|
68
|
+
success?: false,
|
|
69
|
+
response_code: 500,
|
|
70
|
+
code_major: 'failure',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
description: 'boom'
|
|
73
|
+
)
|
|
74
|
+
expect(tool_provider_double).to receive(:post_extended_replace_result!).with(opts).and_return(failure_result)
|
|
75
|
+
|
|
76
|
+
expect {
|
|
77
|
+
job.perform(organization, opts)
|
|
78
|
+
}.to raise_error(PandaPal::Jobs::GradePassbackFailure)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe PandaPal::SecureHeaders do
|
|
4
|
+
subject(:config) { SecureHeaders::Configuration.new }
|
|
5
|
+
|
|
6
|
+
describe '.apply_defaults' do
|
|
7
|
+
before { described_class.apply_defaults(config) }
|
|
8
|
+
|
|
9
|
+
it 'returns the same configuration object it was given' do
|
|
10
|
+
expect(described_class.apply_defaults(config)).to equal(config)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'relaxes the default cookie samesite restriction for LTI iframes' do
|
|
14
|
+
expect(config.cookies).to eq(samesite: { none: true })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'does not mark cookies secure outside of production' do
|
|
18
|
+
expect(config.cookies[:secure]).to be_nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'allows the app to be embedded in LTI iframes' do
|
|
22
|
+
expect(config.x_frame_options).to eq('ALLOWALL')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'sets baseline browser protection headers' do
|
|
26
|
+
expect(config.x_content_type_options).to eq('nosniff')
|
|
27
|
+
expect(config.x_xss_protection).to eq('1; mode=block')
|
|
28
|
+
expect(config.referrer_policy).to eq(%w(origin-when-cross-origin strict-origin-when-cross-origin))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'restricts default_src, connect_src, and script_src to self' do
|
|
32
|
+
expect(config.csp[:default_src]).to include("'self'")
|
|
33
|
+
expect(config.csp[:connect_src]).to include("'self'")
|
|
34
|
+
expect(config.csp[:script_src]).to include("'self'")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'allows inline styles and Google Fonts for CSS-in-JS libraries' do
|
|
38
|
+
expect(config.csp[:style_src]).to include("'self'", "'unsafe-inline'", 'blob:', 'https://fonts.googleapis.com')
|
|
39
|
+
expect(config.csp[:font_src]).to include("'self'", 'data:', 'https://fonts.gstatic.com')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'does not add development-only CSP allowances outside of development' do
|
|
43
|
+
expect(config.csp[:script_src]).not_to include("'unsafe-eval'")
|
|
44
|
+
expect(config.csp[:connect_src]).not_to include('http://localhost:3035')
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '.apply_defaults in production' do
|
|
49
|
+
around do |example|
|
|
50
|
+
original_env = Rails.env
|
|
51
|
+
begin
|
|
52
|
+
Rails.env = 'production'
|
|
53
|
+
example.run
|
|
54
|
+
ensure
|
|
55
|
+
Rails.env = original_env
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'marks cookies as secure' do
|
|
60
|
+
described_class.apply_defaults(config)
|
|
61
|
+
expect(config.cookies[:secure]).to be true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '.csp_entry' do
|
|
66
|
+
it 'merges values into the directive without duplicating existing entries' do
|
|
67
|
+
config.csp[:default_src] = ["'self'"]
|
|
68
|
+
described_class.instance_variable_set(:@config, config)
|
|
69
|
+
|
|
70
|
+
described_class.send(:csp_entry, :default_src, "'self'", 'https://example.com')
|
|
71
|
+
|
|
72
|
+
expect(config.csp[:default_src]).to eq(["'self'", 'https://example.com'])
|
|
73
|
+
ensure
|
|
74
|
+
described_class.instance_variable_set(:@config, nil)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module PandaPal
|
|
4
|
+
RSpec.describe Platform, type: :model do
|
|
5
|
+
describe '.resolve_platform / .resolve_platform_class' do
|
|
6
|
+
context 'with the default configuration (no :platform option set)' do
|
|
7
|
+
it 'resolves to the Canvas platform class' do
|
|
8
|
+
expect(Platform.resolve_platform_class(iss: 'https://canvas.instructure.com')).to eq(Platform::Canvas)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'resolves to an instance of the Canvas platform, initialized with the issuer' do
|
|
12
|
+
resolved = Platform.resolve_platform(iss: 'https://canvas.instructure.com')
|
|
13
|
+
|
|
14
|
+
expect(resolved).to be_a(Platform::Canvas)
|
|
15
|
+
expect(resolved.platform_uri).to eq('https://canvas.instructure.com')
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
context 'when :platform is configured as a class name string' do
|
|
20
|
+
before do
|
|
21
|
+
PandaPal.lti_options = PandaPal.lti_options.merge(platform: 'PandaPal::Platform::Generic')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'resolves to that class' do
|
|
25
|
+
expect(Platform.resolve_platform_class(iss: 'https://lms.example.com')).to eq(Platform::Generic)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'resolves to an instance of that class' do
|
|
29
|
+
resolved = Platform.resolve_platform(iss: 'https://lms.example.com')
|
|
30
|
+
expect(resolved).to be_a(Platform::Generic)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
context 'when :platform is configured as a Proc' do
|
|
35
|
+
it 'uses the class returned by the proc' do
|
|
36
|
+
PandaPal.lti_options = PandaPal.lti_options.merge(
|
|
37
|
+
platform: proc { |params| params[:iss] == 'https://special.example.com' ? Platform::Generic : nil }
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(Platform.resolve_platform_class(iss: 'https://special.example.com')).to eq(Platform::Generic)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'falls through to the trusted-issuer default when the proc returns nil' do
|
|
44
|
+
PandaPal.lti_options = PandaPal.lti_options.merge(platform: proc { |_params| nil })
|
|
45
|
+
|
|
46
|
+
expect(Platform.resolve_platform_class(iss: 'https://canvas.beta.instructure.com')).to eq(Platform::Canvas)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'when :platform is configured as a Symbol' do
|
|
51
|
+
before do
|
|
52
|
+
Object.define_singleton_method(:panda_pal_spec_platform_resolver) { |_params| Platform::Generic }
|
|
53
|
+
PandaPal.lti_options = PandaPal.lti_options.merge(platform: :panda_pal_spec_platform_resolver)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
after do
|
|
57
|
+
Object.singleton_class.send(:remove_method, :panda_pal_spec_platform_resolver)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'calls the named method on Object and uses the returned class' do
|
|
61
|
+
expect(Platform.resolve_platform_class(iss: 'https://lms.example.com')).to eq(Platform::Generic)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
context 'when the configured platform string does not resolve to a class' do
|
|
66
|
+
before do
|
|
67
|
+
PandaPal.lti_options = PandaPal.lti_options.merge(platform: 'NotARealPlatformClassXYZ')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'falls back to Canvas for a trusted Canvas issuer' do
|
|
71
|
+
expect(Platform.resolve_platform_class(iss: 'https://canvas.beta.instructure.com')).to eq(Platform::Canvas)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'raises for an untrusted issuer' do
|
|
75
|
+
expect {
|
|
76
|
+
Platform.resolve_platform_class(iss: 'https://not-trusted.example.com')
|
|
77
|
+
}.to raise_error(RuntimeError, /Unknown platform/)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe Platform::Generic do
|
|
83
|
+
describe '.from_urls' do
|
|
84
|
+
it 'stores the given URLs for later use' do
|
|
85
|
+
platform = described_class.from_urls(
|
|
86
|
+
'https://lms.example.com',
|
|
87
|
+
jwks: '/custom/jwks',
|
|
88
|
+
auth_redirect: '/custom/auth',
|
|
89
|
+
grant: '/custom/grant'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(platform.options).to eq(
|
|
93
|
+
base_url: 'https://lms.example.com',
|
|
94
|
+
jwks_url: '/custom/jwks',
|
|
95
|
+
auth_redirect_url: '/custom/auth',
|
|
96
|
+
grant_url: '/custom/grant'
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'derives platform_uri from base_url' do
|
|
101
|
+
platform = described_class.from_urls('https://lms.example.com')
|
|
102
|
+
|
|
103
|
+
expect(platform.platform_uri).to eq('https://lms.example.com')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'derives jwks_url from base_url, using the default path when none given' do
|
|
107
|
+
platform = described_class.from_urls('https://lms.example.com')
|
|
108
|
+
|
|
109
|
+
expect(platform.jwks_url).to eq('https://lms.example.com/api/lti/security/jwks')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'derives jwks_url from a custom path when given' do
|
|
113
|
+
platform = described_class.from_urls('https://lms.example.com', jwks: '/custom/jwks')
|
|
114
|
+
|
|
115
|
+
expect(platform.jwks_url).to eq('https://lms.example.com/custom/jwks')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'derives authentication_redirect_url from base_url, using the default path when none given' do
|
|
119
|
+
platform = described_class.from_urls('https://lms.example.com')
|
|
120
|
+
|
|
121
|
+
expect(platform.authentication_redirect_url).to eq('https://lms.example.com/api/lti/authorize_redirect')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'derives grant_url from base_url, using the default path when none given' do
|
|
125
|
+
platform = described_class.from_urls('https://lms.example.com')
|
|
126
|
+
|
|
127
|
+
expect(platform.grant_url).to eq('https://lms.example.com/login/oauth2/token')
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe '#serialize / .from_serialized' do
|
|
133
|
+
it 'round-trips a Canvas platform' do
|
|
134
|
+
platform = Platform::Canvas.new(iss: 'https://canvas.instructure.com')
|
|
135
|
+
restored = Platform.from_serialized(platform.serialize)
|
|
136
|
+
|
|
137
|
+
expect(restored).to be_a(Platform::Canvas)
|
|
138
|
+
expect(restored.platform_uri).to eq(platform.platform_uri)
|
|
139
|
+
expect(restored.jwks_url).to eq(platform.jwks_url)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'round-trips a Generic platform' do
|
|
143
|
+
platform = Platform::Generic.from_urls('https://lms.example.com', jwks: '/custom/jwks')
|
|
144
|
+
restored = Platform.from_serialized(platform.serialize)
|
|
145
|
+
|
|
146
|
+
expect(restored).to be_a(Platform::Generic)
|
|
147
|
+
expect(restored.platform_uri).to eq(platform.platform_uri)
|
|
148
|
+
expect(restored.jwks_url).to eq(platform.jwks_url)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
module PandaPal
|
|
6
|
+
RSpec.describe Session, type: :model do
|
|
7
|
+
describe '#canvas_account_role_labels' do
|
|
8
|
+
# The Tool is installed on a SUB-ACCOUNT here, which is the only topology
|
|
9
|
+
# that distinguishes the install Account from Canvas's own root. A Tool
|
|
10
|
+
# installed at the top is the degenerate case where every reading of
|
|
11
|
+
# "root" agrees, so it can't tell a correct implementation from a broken
|
|
12
|
+
# one.
|
|
13
|
+
#
|
|
14
|
+
# 1 root
|
|
15
|
+
# └── 20 parent
|
|
16
|
+
# └── 300 install <- current_organization.canvas_account_id
|
|
17
|
+
# └── 4000 child
|
|
18
|
+
# └── 21 sibling
|
|
19
|
+
let(:canvas_user_id) { 9001 }
|
|
20
|
+
let!(:organization) { create(:panda_pal_organization, canvas_account_id: 300) }
|
|
21
|
+
# Created BEFORE the tenant switch below: panda_pal_sessions lives outside
|
|
22
|
+
# the tenant schema, while accounts/admins live inside it.
|
|
23
|
+
let!(:session) do
|
|
24
|
+
create(:panda_pal_session, panda_pal_organization: organization, data: {
|
|
25
|
+
launch_params: {
|
|
26
|
+
"https://purl.imsglobal.org/spec/lti/claim/custom" => { "canvas_user_id" => canvas_user_id },
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def admin!(account_id, role_name: "AccountAdmin", workflow_state: "active")
|
|
32
|
+
Admin.create!(canvas_user_id: canvas_user_id, canvas_account_id: account_id,
|
|
33
|
+
role_name: role_name, workflow_state: workflow_state)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
before do
|
|
37
|
+
organization.switch_tenant
|
|
38
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil, name: "root")
|
|
39
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 1, name: "parent")
|
|
40
|
+
Account.create!(canvas_id: 21, canvas_parent_account_id: 1, name: "sibling")
|
|
41
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20, name: "install")
|
|
42
|
+
Account.create!(canvas_id: 4000, canvas_parent_account_id: 300, name: "child")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
after { Apartment::Tenant.reset }
|
|
46
|
+
|
|
47
|
+
context 'by default (inherited: false)' do
|
|
48
|
+
it 'returns roles held at the install Account' do
|
|
49
|
+
admin!(300, role_name: "AccountAdmin")
|
|
50
|
+
|
|
51
|
+
expect(session.canvas_account_role_labels).to eq(["AccountAdmin"])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'ignores roles held above the install Account' do
|
|
55
|
+
admin!(1, role_name: "AccountAdmin")
|
|
56
|
+
|
|
57
|
+
expect(session.canvas_account_role_labels).to eq([])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'treats :root as the install Account, not as Canvas account 1' do
|
|
61
|
+
admin!(300, role_name: "Ops")
|
|
62
|
+
admin!(1, role_name: "AccountAdmin")
|
|
63
|
+
|
|
64
|
+
expect(session.canvas_account_role_labels(:root)).to eq(["Ops"])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'reads an explicitly requested Account' do
|
|
68
|
+
admin!(4000, role_name: "Sub-Account Admin")
|
|
69
|
+
|
|
70
|
+
expect(session.canvas_account_role_labels(4000)).to eq(["Sub-Account Admin"])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'with inherited: true' do
|
|
75
|
+
it 'includes a role held on the immediate parent' do
|
|
76
|
+
admin!(20, role_name: "AccountAdmin")
|
|
77
|
+
|
|
78
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq(["AccountAdmin"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'includes a role held on Canvas\'s root Account, several levels up' do
|
|
82
|
+
admin!(1, role_name: "AccountAdmin")
|
|
83
|
+
|
|
84
|
+
expect(session.canvas_account_role_labels(:root, inherited: true)).to eq(["AccountAdmin"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'still includes a role held at the Account itself' do
|
|
88
|
+
admin!(300, role_name: "AccountAdmin")
|
|
89
|
+
|
|
90
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq(["AccountAdmin"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'does NOT include a role held BELOW the Account' do
|
|
94
|
+
admin!(4000, role_name: "AccountAdmin")
|
|
95
|
+
|
|
96
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'does NOT include a role held on a sibling branch' do
|
|
100
|
+
admin!(21, role_name: "AccountAdmin")
|
|
101
|
+
|
|
102
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'excludes inactive rows anywhere in the chain' do
|
|
106
|
+
admin!(1, role_name: "AccountAdmin", workflow_state: "inactive")
|
|
107
|
+
|
|
108
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'ignores another user\'s roles in the chain' do
|
|
112
|
+
Admin.create!(canvas_user_id: canvas_user_id + 1, canvas_account_id: 1, role_name: "AccountAdmin")
|
|
113
|
+
|
|
114
|
+
expect(session.canvas_account_role_labels(inherited: true)).to eq([])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'gathers roles from several levels at once' do
|
|
118
|
+
admin!(300, role_name: "Ops")
|
|
119
|
+
admin!(20, role_name: "Department Admin")
|
|
120
|
+
admin!(1, role_name: "AccountAdmin")
|
|
121
|
+
|
|
122
|
+
expect(session.canvas_account_role_labels(inherited: true))
|
|
123
|
+
.to contain_exactly("Ops", "Department Admin", "AccountAdmin")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'walks up from an explicitly requested Account, not from the install' do
|
|
127
|
+
admin!(300, role_name: "AccountAdmin")
|
|
128
|
+
|
|
129
|
+
expect(session.canvas_account_role_labels(4000, inherited: true)).to eq(["AccountAdmin"])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'accepts an Account-like object' do
|
|
133
|
+
admin!(1, role_name: "AccountAdmin")
|
|
134
|
+
|
|
135
|
+
expect(session.canvas_account_role_labels(Account.find_by(canvas_id: 300), inherited: true))
|
|
136
|
+
.to eq(["AccountAdmin"])
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '#canvas_account_ancestry' do
|
|
142
|
+
let!(:organization) { create(:panda_pal_organization, canvas_account_id: 300) }
|
|
143
|
+
let!(:session) { create(:panda_pal_session, panda_pal_organization: organization, data: {}) }
|
|
144
|
+
|
|
145
|
+
before { organization.switch_tenant }
|
|
146
|
+
after { Apartment::Tenant.reset }
|
|
147
|
+
|
|
148
|
+
it 'returns the Account plus its ancestors, nearest first' do
|
|
149
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil)
|
|
150
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 1)
|
|
151
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20)
|
|
152
|
+
|
|
153
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300, 20, 1 ])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'returns just the Account when it is already the top' do
|
|
157
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil)
|
|
158
|
+
|
|
159
|
+
expect(session.canvas_account_ancestry(1)).to eq([ 1 ])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'returns the id unchanged when the Account is not in the local mirror' do
|
|
163
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300 ])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'stops rather than looping forever on a cyclic hierarchy' do
|
|
167
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20)
|
|
168
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 300)
|
|
169
|
+
|
|
170
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300, 20 ])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'keeps walking through a deleted intermediate Account' do
|
|
174
|
+
Account.create!(canvas_id: 1, canvas_parent_account_id: nil)
|
|
175
|
+
Account.create!(canvas_id: 20, canvas_parent_account_id: 1, workflow_state: "deleted")
|
|
176
|
+
Account.create!(canvas_id: 300, canvas_parent_account_id: 20)
|
|
177
|
+
|
|
178
|
+
expect(session.canvas_account_ancestry(300)).to eq([ 300, 20, 1 ])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -5,5 +5,151 @@ module PandaPal
|
|
|
5
5
|
it 'is initialized with a session_secret' do
|
|
6
6
|
expect(PandaPal::Session.new.session_secret).to_not be_nil
|
|
7
7
|
end
|
|
8
|
+
|
|
9
|
+
describe '.for_panda_token' do
|
|
10
|
+
it 'returns nil for a nil token' do
|
|
11
|
+
expect(Session.for_panda_token(nil)).to be_nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns nil for an empty token' do
|
|
15
|
+
expect(Session.for_panda_token('')).to be_nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
context 'with a KEY-type token' do
|
|
19
|
+
let(:session) { create(:panda_pal_session) }
|
|
20
|
+
|
|
21
|
+
it 'returns the matching session record' do
|
|
22
|
+
found = Session.for_panda_token(session.session_key, enforce_tenant: false)
|
|
23
|
+
expect(found).to eq(session)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'raises SessionNonceMismatch when the secret in the signature is wrong' do
|
|
27
|
+
header, _sig = session.session_key.split('.')
|
|
28
|
+
tampered_token = "#{header}.not-the-real-secret"
|
|
29
|
+
|
|
30
|
+
expect {
|
|
31
|
+
Session.for_panda_token(tampered_token, enforce_tenant: false)
|
|
32
|
+
}.to raise_error(Session::SessionNonceMismatch)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'raises SessionNonceMismatch when the payload session id does not match the found record' do
|
|
36
|
+
other_session = create(:panda_pal_session)
|
|
37
|
+
|
|
38
|
+
header, sig = session.session_key.split('.')
|
|
39
|
+
payload = JSON.parse(Base64.urlsafe_decode64(header))
|
|
40
|
+
payload['s'] = other_session.id
|
|
41
|
+
tampered_header = Base64.urlsafe_encode64(payload.to_json)
|
|
42
|
+
tampered_token = "#{tampered_header}.#{sig}"
|
|
43
|
+
|
|
44
|
+
expect {
|
|
45
|
+
Session.for_panda_token(tampered_token, enforce_tenant: false)
|
|
46
|
+
}.to raise_error(Session::SessionNonceMismatch)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#sign_value / #validate_signature' do
|
|
52
|
+
it 'produces a base64-encoded HMAC-SHA256 digest without relying on OpenSSL::HMAC.base64digest' do
|
|
53
|
+
session = build(:panda_pal_session)
|
|
54
|
+
|
|
55
|
+
# OpenSSL::HMAC.base64digest doesn't exist at all on some Rubies' bundled
|
|
56
|
+
# openssl gem (that's the bug this test guards against) -- only assert the
|
|
57
|
+
# negative expectation where the method exists to negate; the correctness
|
|
58
|
+
# check below is what actually proves sign_value doesn't depend on it.
|
|
59
|
+
expect(OpenSSL::HMAC).not_to receive(:base64digest) if OpenSSL::HMAC.respond_to?(:base64digest)
|
|
60
|
+
signature = session.sign_value('some-data-to-sign')
|
|
61
|
+
|
|
62
|
+
expected = Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), session.session_secret, 'some-data-to-sign'))
|
|
63
|
+
expect(signature).to eq(expected)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'validates a signature produced by the same session' do
|
|
67
|
+
session = build(:panda_pal_session)
|
|
68
|
+
signature = session.sign_value('some-data-to-sign')
|
|
69
|
+
|
|
70
|
+
expect(session.validate_signature('some-data-to-sign', signature)).to be true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'does not validate a signature produced by a different session' do
|
|
74
|
+
session = build(:panda_pal_session)
|
|
75
|
+
other_session = build(:panda_pal_session)
|
|
76
|
+
|
|
77
|
+
signature_from_other = other_session.sign_value('some-data-to-sign')
|
|
78
|
+
|
|
79
|
+
expect(session.validate_signature('some-data-to-sign', signature_from_other)).to be false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '.extract_panda_token' do
|
|
84
|
+
it 'extracts the token from the X-Panda-Token header (HTTP_ form)' do
|
|
85
|
+
request = double('request', headers: { 'HTTP_X_PANDA_TOKEN' => 'header.token' })
|
|
86
|
+
expect(Session.extract_panda_token(request, {})).to eq('header.token')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'extracts the token from the X-Panda-Token header (raw form)' do
|
|
90
|
+
request = double('request', headers: { 'X-Panda-Token' => 'header.token' })
|
|
91
|
+
expect(Session.extract_panda_token(request, {})).to eq('header.token')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'falls back to request.env when the request does not respond to headers' do
|
|
95
|
+
request = double('request', env: { 'HTTP_X_PANDA_TOKEN' => 'env.token' })
|
|
96
|
+
expect(Session.extract_panda_token(request, {})).to eq('env.token')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'extracts the token from an "Authorization: Bearer panda:<token>" header' do
|
|
100
|
+
request = double('request', headers: { 'HTTP_AUTHORIZATION' => 'Bearer panda:bearer.token' })
|
|
101
|
+
expect(Session.extract_panda_token(request, {})).to eq('bearer.token')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'extracts the token from the raw Authorization header key' do
|
|
105
|
+
request = double('request', headers: { 'Authorization' => 'Bearer panda:bearer.token' })
|
|
106
|
+
expect(Session.extract_panda_token(request, {})).to eq('bearer.token')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'extracts the token from the legacy "Authorization: token=<token>" header format' do
|
|
110
|
+
request = double('request', headers: { 'HTTP_AUTHORIZATION' => 'token=legacy.token' })
|
|
111
|
+
expect(Session.extract_panda_token(request, {})).to eq('legacy.token')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'extracts the token from params["panda_token"]' do
|
|
115
|
+
request = double('request', headers: {})
|
|
116
|
+
expect(Session.extract_panda_token(request, { 'panda_token' => 'params.token' })).to eq('params.token')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'extracts the token from the legacy params["session_token"]' do
|
|
120
|
+
request = double('request', headers: {})
|
|
121
|
+
expect(Session.extract_panda_token(request, { 'session_token' => 'legacy.session.token' })).to eq('legacy.session.token')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'extracts the token from the legacy params["session_key"]' do
|
|
125
|
+
request = double('request', headers: {})
|
|
126
|
+
expect(Session.extract_panda_token(request, { 'session_key' => 'legacy.session.key.token' })).to eq('legacy.session.key.token')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'returns nil when nothing is present' do
|
|
130
|
+
request = double('request', headers: {})
|
|
131
|
+
expect(Session.extract_panda_token(request, {})).to be_nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
describe '.format_url_for_signing' do
|
|
136
|
+
it 'strips panda_token, session_key, and session_token but keeps other params' do
|
|
137
|
+
url = 'https://example.com/some/path?panda_token=tok&foo=bar&session_key=abc&baz=qux&session_token=def'
|
|
138
|
+
|
|
139
|
+
expect(Session.format_url_for_signing(url)).to eq('/some/path?foo=bar&baz=qux')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'accepts a URI object directly' do
|
|
143
|
+
uri = URI.parse('https://example.com/some/path?panda_token=tok&foo=bar')
|
|
144
|
+
|
|
145
|
+
expect(Session.format_url_for_signing(uri)).to eq('/some/path?foo=bar')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'produces an empty query string when no params remain' do
|
|
149
|
+
url = 'https://example.com/some/path?panda_token=tok'
|
|
150
|
+
|
|
151
|
+
expect(Session.format_url_for_signing(url)).to eq('/some/path?')
|
|
152
|
+
end
|
|
153
|
+
end
|
|
8
154
|
end
|
|
9
155
|
end
|
data/spec/spec_helper.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: panda_pal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Instructure CustomDev
|
|
@@ -55,30 +55,30 @@ dependencies:
|
|
|
55
55
|
name: browser
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
|
-
- -
|
|
58
|
+
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version:
|
|
60
|
+
version: '5.3'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
|
-
- -
|
|
65
|
+
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version:
|
|
67
|
+
version: '5.3'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: attr_encrypted
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 4.
|
|
74
|
+
version: '4.2'
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 4.
|
|
81
|
+
version: '4.2'
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: secure_headers
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -197,17 +197,25 @@ files:
|
|
|
197
197
|
- lib/tasks/panda_pal_tasks.rake
|
|
198
198
|
- panda_pal.gemspec
|
|
199
199
|
- spec/controllers/panda_pal/api_call_controller_spec.rb
|
|
200
|
+
- spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
|
|
201
|
+
- spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
|
|
200
202
|
- spec/core/apartment_multidb_spec.rb
|
|
201
203
|
- spec/factories/panda_pal_organizations.rb
|
|
202
204
|
- spec/factories/panda_pal_sessions.rb
|
|
205
|
+
- spec/internal/app/models/account.rb
|
|
206
|
+
- spec/internal/app/models/admin.rb
|
|
203
207
|
- spec/internal/config/database.yml
|
|
204
208
|
- spec/internal/config/routes.rb
|
|
205
209
|
- spec/internal/config/storage.yml
|
|
206
210
|
- spec/internal/db/schema.rb
|
|
211
|
+
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
212
|
+
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
207
213
|
- spec/models/panda_pal/api_call_spec.rb
|
|
208
214
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
209
215
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
210
216
|
- spec/models/panda_pal/organization_spec.rb
|
|
217
|
+
- spec/models/panda_pal/platform_spec.rb
|
|
218
|
+
- spec/models/panda_pal/session_account_roles_spec.rb
|
|
211
219
|
- spec/models/panda_pal/session_spec.rb
|
|
212
220
|
- spec/spec_helper.rb
|
|
213
221
|
homepage: http://instructure.com
|
|
@@ -233,16 +241,24 @@ specification_version: 4
|
|
|
233
241
|
summary: LTI mountable engine
|
|
234
242
|
test_files:
|
|
235
243
|
- spec/controllers/panda_pal/api_call_controller_spec.rb
|
|
244
|
+
- spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
|
|
245
|
+
- spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
|
|
236
246
|
- spec/core/apartment_multidb_spec.rb
|
|
237
247
|
- spec/factories/panda_pal_organizations.rb
|
|
238
248
|
- spec/factories/panda_pal_sessions.rb
|
|
249
|
+
- spec/internal/app/models/account.rb
|
|
250
|
+
- spec/internal/app/models/admin.rb
|
|
239
251
|
- spec/internal/config/database.yml
|
|
240
252
|
- spec/internal/config/routes.rb
|
|
241
253
|
- spec/internal/config/storage.yml
|
|
242
254
|
- spec/internal/db/schema.rb
|
|
255
|
+
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
256
|
+
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
243
257
|
- spec/models/panda_pal/api_call_spec.rb
|
|
244
258
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
245
259
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
246
260
|
- spec/models/panda_pal/organization_spec.rb
|
|
261
|
+
- spec/models/panda_pal/platform_spec.rb
|
|
262
|
+
- spec/models/panda_pal/session_account_roles_spec.rb
|
|
247
263
|
- spec/models/panda_pal/session_spec.rb
|
|
248
264
|
- spec/spec_helper.rb
|