panda_pal 5.16.20 → 5.16.21
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 +1 -1
- data/config/initializers/apartment.rb +6 -0
- data/lib/panda_pal/engine.rb +6 -6
- 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/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_spec.rb +146 -0
- data/spec/spec_helper.rb +7 -0
- metadata +17 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a5f35f89d8e121395cef557e6305357a29eab6ea9a63434ec3a38548c92d566
|
|
4
|
+
data.tar.gz: 6fccf06a199c9c59c01ebb0981cbfb4f86b374303cd7a30521fa0fa604705b9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b23c1838a9a7b19a8c82d0f57f24d5c2b97ed4d2265379c52832b00ce7fd742bad1388d0697391a594908e2a1dac0f3b65c28cbbf1d2dbe6d1ec3ae6ba2cd68
|
|
7
|
+
data.tar.gz: 578b81b0921ae8fa4ed8b8bb99ed2c00326fa354c08dc8ff9aca4d354a752aded035aee21658654283369bf29e74eee17b7ba4f900e8ad8fe6594d55ead280fd
|
|
@@ -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)
|
|
@@ -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,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
|
|
@@ -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.16.
|
|
4
|
+
version: 5.16.21
|
|
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,6 +197,8 @@ 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
|
|
@@ -204,10 +206,13 @@ files:
|
|
|
204
206
|
- spec/internal/config/routes.rb
|
|
205
207
|
- spec/internal/config/storage.yml
|
|
206
208
|
- spec/internal/db/schema.rb
|
|
209
|
+
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
210
|
+
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
207
211
|
- spec/models/panda_pal/api_call_spec.rb
|
|
208
212
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
209
213
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
210
214
|
- spec/models/panda_pal/organization_spec.rb
|
|
215
|
+
- spec/models/panda_pal/platform_spec.rb
|
|
211
216
|
- spec/models/panda_pal/session_spec.rb
|
|
212
217
|
- spec/spec_helper.rb
|
|
213
218
|
homepage: http://instructure.com
|
|
@@ -233,6 +238,8 @@ specification_version: 4
|
|
|
233
238
|
summary: LTI mountable engine
|
|
234
239
|
test_files:
|
|
235
240
|
- spec/controllers/panda_pal/api_call_controller_spec.rb
|
|
241
|
+
- spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
|
|
242
|
+
- spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
|
|
236
243
|
- spec/core/apartment_multidb_spec.rb
|
|
237
244
|
- spec/factories/panda_pal_organizations.rb
|
|
238
245
|
- spec/factories/panda_pal_sessions.rb
|
|
@@ -240,9 +247,12 @@ test_files:
|
|
|
240
247
|
- spec/internal/config/routes.rb
|
|
241
248
|
- spec/internal/config/storage.yml
|
|
242
249
|
- spec/internal/db/schema.rb
|
|
250
|
+
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
251
|
+
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
243
252
|
- spec/models/panda_pal/api_call_spec.rb
|
|
244
253
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
245
254
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
246
255
|
- spec/models/panda_pal/organization_spec.rb
|
|
256
|
+
- spec/models/panda_pal/platform_spec.rb
|
|
247
257
|
- spec/models/panda_pal/session_spec.rb
|
|
248
258
|
- spec/spec_helper.rb
|