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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac02fef85d3184d63c9c8573a8695e52a424678747ab7a541a5f9483167ccc18
4
- data.tar.gz: 54c1fbd8ad4d2f352f1d9b2843ef4c2705c63c8892942cedc8e5784840cb7820
3
+ metadata.gz: aa9bee5cce120a4bf340d69ebd142e6f11f8d65bb0b4d850bb781662469ba84e
4
+ data.tar.gz: b1040ec6ecbd33c7ff5fe0eef396a2e5d1764ec893a1c439b5cd8de0ec7d726c
5
5
  SHA512:
6
- metadata.gz: c64c54373faafee3164496661d8bed6ae5a68d2852296966b1ffef4d738c82f29c675694bcbf0f7bc5f7c22246c6cacedc676393a7bcfa7bb4fd998d0033bcd5
7
- data.tar.gz: aeb94f2be05936dc753d66172426d028ae1b47417410c506e819b76b40c8adb8932fb0ec8dc9933497b676f414794cc285ba36408daad537ca18ef0c2febeb9a
6
+ metadata.gz: 03ffb7a4bf0fe654ad310e8a8c34e69b1297ed02ab3a38a989d0712a630e2c41ca52cb76333ef0d400acabd3688a5c2d33621ab24d59027698e444581260403d
7
+ data.tar.gz: 522d2c9847b0225228d808a053a0eb278d872750883758bc60ccb87128162645fe989d7fa1e14f7fb9384fea69e7fae540dcaf02d4927f1691dc21c1f53223fe
@@ -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)
@@ -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, defaulting to the Root Account
243
- def canvas_account_role_labels(account = 'self')
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 = current_organization.canvas_account_id if account == 'self'
249
- adm_query = ::Admin.where(canvas_account_id: account, workflow_state: "active", canvas_user_id: canvas_user_id)
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
- admin_entries = canvas_sync_client.account_admins(account, user_id: [canvas_user_id])
254
- admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
255
- admin_entries.map{|ent| ent[:role] }
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)
@@ -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
 
@@ -114,7 +114,7 @@ module PandaPal::Helpers
114
114
  @current_session.save!
115
115
 
116
116
  @decoded_lti_jwt = decoded_jwt
117
- rescue JSON::JWT::VerificationFailed => e
117
+ rescue JSON::JWT::Exception => e
118
118
  payload = Array(e.message)
119
119
 
120
120
  render json: {
@@ -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.17.0'
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,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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal stand-in for the CanvasSync-generated Admin roster model that
4
+ # Session#canvas_account_role_labels reads role labels from.
5
+ class Admin < ActiveRecord::Base
6
+ end
@@ -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
@@ -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.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: 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,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