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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac02fef85d3184d63c9c8573a8695e52a424678747ab7a541a5f9483167ccc18
4
- data.tar.gz: 54c1fbd8ad4d2f352f1d9b2843ef4c2705c63c8892942cedc8e5784840cb7820
3
+ metadata.gz: 7a5f35f89d8e121395cef557e6305357a29eab6ea9a63434ec3a38548c92d566
4
+ data.tar.gz: 6fccf06a199c9c59c01ebb0981cbfb4f86b374303cd7a30521fa0fa604705b9c
5
5
  SHA512:
6
- metadata.gz: c64c54373faafee3164496661d8bed6ae5a68d2852296966b1ffef4d738c82f29c675694bcbf0f7bc5f7c22246c6cacedc676393a7bcfa7bb4fd998d0033bcd5
7
- data.tar.gz: aeb94f2be05936dc753d66172426d028ae1b47417410c506e819b76b40c8adb8932fb0ec8dc9933497b676f414794cc285ba36408daad537ca18ef0c2febeb9a
6
+ metadata.gz: 4b23c1838a9a7b19a8c82d0f57f24d5c2b97ed4d2265379c52832b00ce7fd742bad1388d0697391a594908e2a1dac0f3b65c28cbbf1d2dbe6d1ec3ae6ba2cd68
7
+ data.tar.gz: 578b81b0921ae8fa4ed8b8bb99ed2c00326fa354c08dc8ff9aca4d354a752aded035aee21658654283369bf29e74eee17b7ba4f900e8ad8fe6594d55ead280fd
@@ -38,7 +38,7 @@ module PandaPal
38
38
  end
39
39
 
40
40
  def is_trusted_env?
41
- return true unless Rails.env.production?
41
+ return true if Rails.env.development? || Rails.env.test?
42
42
 
43
43
  TRUSTED_ISSUERS.include?(platform_uri)
44
44
  end
@@ -96,6 +96,8 @@ module PandaPal
96
96
  end
97
97
 
98
98
  class Platform::Generic < Platform
99
+ attr_reader :options
100
+
99
101
  def self.from_urls(base_url, jwks: nil, auth_redirect: nil, grant: nil)
100
102
  new({
101
103
  base_url: base_url,
@@ -152,7 +152,7 @@ module PandaPal
152
152
  end
153
153
 
154
154
  def sign_value(data)
155
- OpenSSL::HMAC.base64digest(OpenSSL::Digest.new('sha256'), signing_key, data)
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)
@@ -94,18 +94,18 @@ module PandaPal
94
94
  end
95
95
 
96
96
  def _panda_pal_console_env
97
- if Rails.env.production?
98
- env = ENV["SENTRY_CURRENT_ENV"].presence || "PROD"
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
- if Rails.env.production?
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.") unless Rails.env.production?
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
 
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.16.20'
2
+ VERSION = '5.16.21'
3
3
  end
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', '2.5.0'
27
- s.add_dependency 'attr_encrypted', '~> 4.0.0'
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
@@ -1,7 +1,6 @@
1
1
  FactoryBot.define do
2
2
  factory :panda_pal_session, class: 'PandaPal::Session' do
3
- session_secret { "MyString" }
4
- organization { nil }
3
+ panda_pal_organization { nil }
5
4
  data { "MyText" }
6
5
  end
7
6
  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
@@ -1,5 +1,12 @@
1
1
  # ENV["RAILS_ENV"] ||= 'test'
2
2
 
3
+ if ENV['COVERAGE']
4
+ require 'simplecov'
5
+ SimpleCov.start do
6
+ add_filter '/spec/'
7
+ end
8
+ end
9
+
3
10
  require 'bundler'
4
11
  Bundler.require :default, :development
5
12
 
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.20
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: 2.5.0
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: 2.5.0
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.0.0
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.0.0
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