panda_pal 5.16.21 → 5.17.1

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: 7a5f35f89d8e121395cef557e6305357a29eab6ea9a63434ec3a38548c92d566
4
- data.tar.gz: 6fccf06a199c9c59c01ebb0981cbfb4f86b374303cd7a30521fa0fa604705b9c
3
+ metadata.gz: 2f32b5b78d11428c9f4be64fc963ae2fac80cae44abfd66dcda0f39be8b17006
4
+ data.tar.gz: cb494809a3aa72956b16bad0b7b17ba095156a1c4a7fa7261a51738d92442630
5
5
  SHA512:
6
- metadata.gz: 4b23c1838a9a7b19a8c82d0f57f24d5c2b97ed4d2265379c52832b00ce7fd742bad1388d0697391a594908e2a1dac0f3b65c28cbbf1d2dbe6d1ec3ae6ba2cd68
7
- data.tar.gz: 578b81b0921ae8fa4ed8b8bb99ed2c00326fa354c08dc8ff9aca4d354a752aded035aee21658654283369bf29e74eee17b7ba4f900e8ad8fe6594d55ead280fd
6
+ metadata.gz: 7819962aa331b91ae3b6ad0d95fdb8e5bf314d691cdaa4f120a1f12b199d26c916a811b9f071b9fd73802c8893ae70b6a6158a7ea9457b602f1ea2aa496d4092
7
+ data.tar.gz: 5cfd2d2f902e658968b3ce2ce2495c5a58e605071db2c97436186b2893f178d2c198856ac42acd9371f6462d108a2a959834958b84ad1fa9d4f9c0740686cc93
@@ -9,6 +9,16 @@ module PandaPal
9
9
  self.data ||= {}.with_indifferent_access
10
10
  end
11
11
 
12
+ # ActiveModel 8.0 skips its own "mark every duped attribute as changed" step
13
+ # when the source record isn't persisted, and validate_v1p3_launch deletes the
14
+ # login session before duping it. Under partial_inserts the resulting INSERT
15
+ # then omits session_secret, which lands NULL and breaks every later
16
+ # panda_token signature check. Force a full row on every dup.
17
+ def initialize_dup(other)
18
+ super
19
+ attribute_names.each { |name| attribute_will_change!(name) }
20
+ end
21
+
12
22
  delegate :dig, to: :data
13
23
 
14
24
  def self.for_request(request, params = request.params, enforce_tenant: true)
@@ -239,22 +249,76 @@ module PandaPal
239
249
  labels.is_a?(String) ? labels.split(',') : []
240
250
  end
241
251
 
242
- # Retrieve the User's Role Labels in the specified Account, defaulting to the Root Account
243
- def canvas_account_role_labels(account = 'self')
252
+ # Retrieve the User's Role Labels in the specified Account.
253
+ #
254
+ # +account+ accepts an Account-like object (anything responding to
255
+ # +canvas_id+), a Canvas Account id, or one of:
256
+ #
257
+ # [<tt>'self'</tt>] the Account this Tool is installed on (the default).
258
+ # [<tt>:root</tt>] a synonym for <tt>'self'</tt>. In an LTI context the
259
+ # installed Account IS the root of everything the Tool can
260
+ # see, and that — deliberately — need not be Canvas's own
261
+ # root Account: a Tool installed on a sub-account has no
262
+ # business reasoning about Accounts above its install.
263
+ #
264
+ # With <tt>inherited: true</tt>, roles held on any ANCESTOR of +account+ are
265
+ # included too, because a Canvas Account Admin administers their Account and
266
+ # everything beneath it — so an Admin above +account+ genuinely holds that
267
+ # role over +account+. Off by default: a caller asking "which roles are
268
+ # granted AT this Account" would not expect inherited ones, so opting in is
269
+ # explicit, as CanvasSync's +canvas_account_admin?+ does.
270
+ def canvas_account_role_labels(account = 'self', inherited: false)
244
271
  account = 'self' if account.to_s == "root"
245
272
  account = account.canvas_id if account.respond_to?(:canvas_id)
246
273
 
247
274
  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)
275
+ account = panda_pal_organization.canvas_account_id if account == 'self'
276
+ accounts = inherited ? canvas_account_ancestry(account) : [account]
277
+ adm_query = ::Admin.where(canvas_account_id: accounts, workflow_state: "active", canvas_user_id: canvas_user_id)
250
278
  adm_query.pluck(:role_name)
251
279
  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] }
280
+ Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, inherited, canvas_user_id], expires_in: 1.hour) do
281
+ accounts = inherited ? canvas_account_ancestry(account) : [account]
282
+ accounts.flat_map { |acct|
283
+ admin_entries = canvas_sync_client.account_admins(acct, user_id: [canvas_user_id])
284
+ admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
285
+ admin_entries.map{|ent| ent[:role] }
286
+ }.uniq
287
+ end
288
+ end
289
+ end
290
+
291
+ # +account+ plus every Account above it, nearest first.
292
+ #
293
+ # Reads the local Account mirror when the host app has one — no API traffic,
294
+ # and it works whether or not that model uses the +ancestry+ gem, since only
295
+ # +canvas_parent_account_id+ is needed. Falls back to walking Canvas.
296
+ #
297
+ # Traversal is topological and deliberately does NOT skip deleted Accounts:
298
+ # dropping one would silently truncate the chain and deny an Admin above it.
299
+ # Whether a role still counts is decided by the Admin record's own
300
+ # +workflow_state+, not by the shape of the tree.
301
+ def canvas_account_ancestry(account)
302
+ ids = [account]
303
+ seen = Set.new([account.to_s])
304
+
305
+ parent_of =
306
+ if defined?(::Account) && ::Account < ::ActiveRecord::Base
307
+ ->(id) { ::Account.find_by(canvas_id: id)&.canvas_parent_account_id }
308
+ else
309
+ ->(id) { canvas_sync_client.account(id)[:parent_account_id] }
256
310
  end
311
+
312
+ current = account
313
+ while (parent = parent_of.call(current)).present?
314
+ break if seen.include?(parent.to_s) # cycle guard — never trust remote topology
315
+
316
+ ids << parent
317
+ seen << parent.to_s
318
+ current = parent
257
319
  end
320
+
321
+ ids
258
322
  end
259
323
 
260
324
  def lti_roles
@@ -329,7 +393,7 @@ module PandaPal
329
393
  ROLE_TYPES.each do |rt|
330
394
  # LIS V1
331
395
  if role.downcase.start_with?(rt[:urn])
332
- return clean_role(ContextTypeURN, role), rt
396
+ return clean_role(rt[:urn], role), rt
333
397
  end
334
398
 
335
399
  # LIS V2
@@ -50,6 +50,34 @@ module Apartment
50
50
  bits[0] = "default" if bits[0].nil? || bits[0].empty?
51
51
  bits
52
52
  end
53
+
54
+ # Lazily switch a long-lived worker thread to `tenant_name` for the duration of the block,
55
+ # without touching the DB up front. Actively switching checks out a connection and runs
56
+ # SET search_path even for work that never queries anything, so this only *stages* the
57
+ # tenant; the ActiveRecord `checkout` hook registered below applies it the moment a
58
+ # connection is actually acquired, and only then.
59
+ #
60
+ # For anything that runs app code on a worker thread outside a normal request/job cycle --
61
+ # ActionCable's `:work` callback below, CableRoom's `Room#around_work` below, or a similar
62
+ # hook of your own -- this is the one place that logic lives; call it instead of
63
+ # reimplementing the stage/release/restore dance yourself.
64
+ def self.with_lazy_tenant(tenant_name)
65
+ Thread.current[:cable_tenant] = {
66
+ adapter: Apartment::Tenant.adapter,
67
+ tenant: tenant_name,
68
+ }
69
+
70
+ # If this thread already holds a connection from earlier work, release it back to the pool
71
+ # so the checkout hook below gets a fresh checkout to apply the schema to.
72
+ pool = Apartment.connection_class.connection_pool
73
+ pool.release_connection if pool.active_connection?
74
+
75
+ Apartment::Tenant.adapter.instance_variable_set(:@current, tenant_name)
76
+
77
+ yield
78
+ ensure
79
+ Thread.current[:cable_tenant] = nil
80
+ end
53
81
  end
54
82
 
55
83
  module Adapters
@@ -319,30 +347,10 @@ ActiveSupport.on_load(:action_cable) do
319
347
  end
320
348
  end
321
349
 
322
- # Lazily switch any worker threads to the correct tenant when they are working
323
- # Actively calling `switch_tenant` checks out a DB connection and calls `SET search_path`.
324
- # The message processing may not interface with the DB, so this would be a huge waste.
325
- # Instead, we ensure that the thread will trigger a :checkout if it needs a connection,
326
- # at which time we hack-in the correct tenant/schema.
327
-
350
+ # Lazily switch any worker threads to the correct tenant when they are working (see
351
+ # Apartment::Tenant.with_lazy_tenant above for why this is lazy, not an active switch).
328
352
  ActionCable::Server::Worker.set_callback :work, :around do |_, blk|
329
- # Bit of a hack, but placing this here ensures the adapter is initialized (since such may incur DB calls (requiring a connection)
330
- # which could then cause `checkout` to recurse)
331
- Thread.current[:cable_tenant] = {
332
- adapter: Apartment::Tenant.adapter,
333
- tenant: connection.tenant,
334
- }
335
-
336
- # If the current thread already has a connection checked out, release it back to the pool so that the later after_checkout
337
- # callback can set the schema properly.
338
- pool = Apartment.connection_class.connection_pool
339
- pool.release_connection if pool.active_connection?
340
-
341
- Apartment::Tenant.adapter.instance_variable_set(:@current, connection.tenant)
342
-
343
- blk.call
344
- ensure
345
- Thread.current[:cable_tenant] = nil
353
+ Apartment::Tenant.with_lazy_tenant(connection.tenant, &blk)
346
354
  end
347
355
 
348
356
  ActiveSupport.on_load(:active_record) do
@@ -358,6 +366,17 @@ ActiveSupport.on_load(:action_cable) do
358
366
  end
359
367
  end
360
368
 
369
+ # cable_room runs a Room's own code -- message handlers, startup, shutdown, snapshotting -- on a
370
+ # Host worker thread, never inside ActionCable's `:work` callback (its worker pool deliberately
371
+ # isn't an ActionCable::Server::Worker subclass, so it doesn't share that callback chain).
372
+ # `Room#around_work` is its equivalent seam; hook it the same lazy way, on every Room, via
373
+ # CableRoom::Room::Base so every app Room picks it up.
374
+ if defined?(CableRoom)
375
+ CableRoom::Room::Base.around_work do |room, blk|
376
+ Apartment::Tenant.with_lazy_tenant(room.tenant, &blk)
377
+ end
378
+ end
379
+
361
380
  if defined?(Delayed)
362
381
  module PandaPal::Plugins
363
382
  class ApartmentDelayedJobsPlugin < ::Delayed::Plugin
@@ -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: {
@@ -27,6 +27,10 @@ module PandaPal
27
27
  csp_entry(:connect_src, "http://localhost:3035")
28
28
  csp_entry(:connect_src, "ws://localhost:3035")
29
29
 
30
+ # Vite dev server
31
+ csp_entry(:connect_src, "http://localhost:3036")
32
+ csp_entry(:connect_src, "ws://localhost:3036")
33
+
30
34
  # Allow stuff like rack-mini-profiler to work in development:
31
35
  # https://github.com/MiniProfiler/rack-mini-profiler/issues/327
32
36
  csp_entry(:script_src, "'unsafe-eval'")
@@ -129,21 +129,23 @@ module PandaPal::Helpers
129
129
  type = type.to_s.downcase
130
130
  type = type[2..] if type.start_with?('t-')
131
131
 
132
+ base_payload = {
133
+ v: 1,
134
+ o: current_organization.id,
135
+ s: current_session.id,
136
+ }
137
+
132
138
  if type == 'url'
133
139
  raise StandardError, "URL is required" unless url.present?
134
140
  to_sign = PandaPal::Session.format_url_for_signing(url)
135
- payload.merge!(
141
+ payload = base_payload.merge(
136
142
  typ: 'T-URI',
137
143
  usig: current_session.sign_value(to_sign),
138
144
  )
139
145
  else
140
146
  @cached_link_nonces ||= {}
141
- @cached_link_nonces[type] ||= begin
142
- payload = {
143
- v: 1,
144
- o: current_organization.id,
145
- s: current_session.id,
146
- }
147
+ payload = @cached_link_nonces[type] ||= begin
148
+ payload = base_payload.dup
147
149
 
148
150
  if type == 'nonce'
149
151
  current_session[:link_nonce] = SecureRandom.hex
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.16.21'
2
+ VERSION = '5.17.1'
3
3
  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,72 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe PandaPal::Helpers::SessionReplacement do
4
+ let(:organization) { create(:panda_pal_organization) }
5
+ let(:session) { PandaPal::Session.create!(panda_pal_organization: organization, data: {}) }
6
+
7
+ let(:host_class) do
8
+ Class.new do
9
+ # Stub the concern's controller-only class macros.
10
+ def self.helper_method(*args); end
11
+ def self.prepend_around_action(*args); end
12
+
13
+ include PandaPal::Helpers::SessionReplacement
14
+
15
+ attr_reader :current_organization, :current_session
16
+
17
+ def initialize(organization, session)
18
+ @current_organization = organization
19
+ @current_session = session
20
+ end
21
+
22
+ def request
23
+ @request ||= Struct.new(:remote_ip).new('127.0.0.1')
24
+ end
25
+ end
26
+ end
27
+
28
+ subject(:controller) { host_class.new(organization, session) }
29
+
30
+ def decoded_payload(token)
31
+ JSON.parse(Base64.urlsafe_decode64(token.split('.').first))
32
+ end
33
+
34
+ describe '#link_nonce' do
35
+ it 'returns the same real payload on repeated calls within one request' do
36
+ first = controller.link_nonce(type: :nonce)
37
+ second = controller.link_nonce(type: :nonce)
38
+ third = controller.link_nonce(type: :nonce)
39
+
40
+ [first, second, third].each do |token|
41
+ expect(token.split('.').first).not_to eq(Base64.urlsafe_encode64('null'))
42
+ payload = decoded_payload(token)
43
+ expect(payload['typ']).to eq('T-NONCE')
44
+ expect(payload['o']).to eq(organization.id)
45
+ expect(payload['s']).to eq(session.id)
46
+ expect(payload['nnc']).to be_present
47
+ end
48
+
49
+ expect(second).to eq(first)
50
+ expect(third).to eq(first)
51
+ end
52
+
53
+ it 'builds a T-URI payload with the org and session the verifier requires' do
54
+ token = controller.link_nonce('https://example.test/a/b', type: :url)
55
+
56
+ payload = decoded_payload(token)
57
+ expect(payload['typ']).to eq('T-URI')
58
+ expect(payload['o']).to eq(organization.id)
59
+ expect(payload['s']).to eq(session.id)
60
+ expect(payload['usig']).to be_present
61
+ end
62
+
63
+ it 'still caches one nonce per type per request rather than rotating it' do
64
+ controller.link_nonce(type: :nonce)
65
+ nonce_after_first = session[:link_nonce]
66
+
67
+ controller.link_nonce(type: :nonce)
68
+
69
+ expect(session[:link_nonce]).to eq(nonce_after_first)
70
+ end
71
+ end
72
+ 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
@@ -6,6 +6,53 @@ module PandaPal
6
6
  expect(PandaPal::Session.new.session_secret).to_not be_nil
7
7
  end
8
8
 
9
+ describe '#dup' do
10
+ # Mirrors validate_v1p3_launch: load the login session, delete it, dup it into
11
+ # the org's tenant. Only fails on Rails 8.0 with partial_inserts left on, so it
12
+ # is the appraisal run that gates this, not the default (Rails 8.1) lockfile.
13
+ it 'persists every attribute, including session_secret, on the duped record' do
14
+ original = PandaPal::Session.find(create(:panda_pal_session).id)
15
+ original_secret = original.session_secret
16
+ original.delete
17
+
18
+ with_partial_inserts(true) do
19
+ duped = original.dup
20
+ duped.save!
21
+
22
+ reloaded = PandaPal::Session.find(duped.id)
23
+ expect(reloaded.session_secret).to eq(original_secret)
24
+ end
25
+ end
26
+
27
+ # Rails 7.0 split partial_writes into partial_inserts/partial_updates; on 6.1
28
+ # the single partial_writes flag is what controls the INSERT.
29
+ def with_partial_inserts(value)
30
+ setting = PandaPal::Session.respond_to?(:partial_inserts) ? 'partial_inserts' : 'partial_writes'
31
+ previous = PandaPal::Session.public_send(setting)
32
+ PandaPal::Session.public_send("#{setting}=", value)
33
+ yield
34
+ ensure
35
+ PandaPal::Session.public_send("#{setting}=", previous) unless previous.nil?
36
+ end
37
+ end
38
+
39
+ describe Session::RoleStore do
40
+ it 'parses an LTI 1.1 (URN-style) institution role' do
41
+ role_store = Session::RoleStore.new('urn:lti:instrole:ims/lis/Administrator')
42
+ expect(role_store.institution_roles).to eq(['administrator'])
43
+ end
44
+
45
+ it 'parses an LTI 1.1 (URN-style) system role' do
46
+ role_store = Session::RoleStore.new('urn:lti:sysrole:ims/lis/SysAdmin')
47
+ expect(role_store.system_roles).to eq(['sys_admin'])
48
+ end
49
+
50
+ it 'parses an LTI 1.3 (URL-style) institution role' do
51
+ role_store = Session::RoleStore.new(['http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator'])
52
+ expect(role_store.institution_roles).to eq(['administrator'])
53
+ end
54
+ end
55
+
9
56
  describe '.for_panda_token' do
10
57
  it 'returns nil for a nil token' do
11
58
  expect(Session.for_panda_token(nil)).to be_nil
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.21
4
+ version: 5.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -202,17 +202,21 @@ files:
202
202
  - spec/core/apartment_multidb_spec.rb
203
203
  - spec/factories/panda_pal_organizations.rb
204
204
  - spec/factories/panda_pal_sessions.rb
205
+ - spec/internal/app/models/account.rb
206
+ - spec/internal/app/models/admin.rb
205
207
  - spec/internal/config/database.yml
206
208
  - spec/internal/config/routes.rb
207
209
  - spec/internal/config/storage.yml
208
210
  - spec/internal/db/schema.rb
209
211
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
210
212
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
213
+ - spec/lib/panda_pal/helpers/session_replacement_spec.rb
211
214
  - spec/models/panda_pal/api_call_spec.rb
212
215
  - spec/models/panda_pal/organization/settings_validation_spec.rb
213
216
  - spec/models/panda_pal/organization/task_scheduling_spec.rb
214
217
  - spec/models/panda_pal/organization_spec.rb
215
218
  - spec/models/panda_pal/platform_spec.rb
219
+ - spec/models/panda_pal/session_account_roles_spec.rb
216
220
  - spec/models/panda_pal/session_spec.rb
217
221
  - spec/spec_helper.rb
218
222
  homepage: http://instructure.com
@@ -243,16 +247,20 @@ test_files:
243
247
  - spec/core/apartment_multidb_spec.rb
244
248
  - spec/factories/panda_pal_organizations.rb
245
249
  - spec/factories/panda_pal_sessions.rb
250
+ - spec/internal/app/models/account.rb
251
+ - spec/internal/app/models/admin.rb
246
252
  - spec/internal/config/database.yml
247
253
  - spec/internal/config/routes.rb
248
254
  - spec/internal/config/storage.yml
249
255
  - spec/internal/db/schema.rb
250
256
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
251
257
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
258
+ - spec/lib/panda_pal/helpers/session_replacement_spec.rb
252
259
  - spec/models/panda_pal/api_call_spec.rb
253
260
  - spec/models/panda_pal/organization/settings_validation_spec.rb
254
261
  - spec/models/panda_pal/organization/task_scheduling_spec.rb
255
262
  - spec/models/panda_pal/organization_spec.rb
256
263
  - spec/models/panda_pal/platform_spec.rb
264
+ - spec/models/panda_pal/session_account_roles_spec.rb
257
265
  - spec/models/panda_pal/session_spec.rb
258
266
  - spec/spec_helper.rb