panda_pal 5.17.0 → 5.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/models/panda_pal/session.rb +11 -1
- data/config/initializers/apartment.rb +62 -27
- data/lib/panda_pal/helpers/secure_headers.rb +4 -0
- data/lib/panda_pal/helpers/session_replacement.rb +9 -7
- data/lib/panda_pal/version.rb +1 -1
- data/spec/core/apartment_multidb_spec.rb +66 -0
- data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +72 -0
- data/spec/models/panda_pal/session_spec.rb +47 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d2cfdbfc9d2c60cc080b545d2ee582ce212623a45c286d8610b8ac5f6c1af37
|
|
4
|
+
data.tar.gz: 4918d3bee6808bfbf22e98e23f3cbedcd493d4ec7c946ac67e0c8ca5485d9eca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e06e419047c6a29e1ed8f03966bae4c31ed9e5b57ad6953133b890f6dab407d384ea1f071e0c88e01f7a69ef790f9b132f98a973aa3602b6b902d4871722799
|
|
7
|
+
data.tar.gz: 6d19e0a9ea13914f8045d62525dd73dcb97aadfc780dc1c4907fe3b80df9483144d1cc3cc087d509aa7cfba344aa64aaa4b7bd36b05fea354201d7e387094db4
|
|
@@ -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)
|
|
@@ -383,7 +393,7 @@ module PandaPal
|
|
|
383
393
|
ROLE_TYPES.each do |rt|
|
|
384
394
|
# LIS V1
|
|
385
395
|
if role.downcase.start_with?(rt[:urn])
|
|
386
|
-
return clean_role(
|
|
396
|
+
return clean_role(rt[:urn], role), rt
|
|
387
397
|
end
|
|
388
398
|
|
|
389
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
|
|
@@ -120,10 +148,7 @@ module Apartment
|
|
|
120
148
|
|
|
121
149
|
raise ActiveRecord::StatementInvalid, "PandaPal/Apartment Mutli-DB support does not currently support DB roles" if ActiveRecord::Base.current_role != ActiveRecord::Base.default_role
|
|
122
150
|
|
|
123
|
-
unless
|
|
124
|
-
Apartment.establish_connection multi_tenantify(tenant, false)
|
|
125
|
-
Apartment.connection.verify!
|
|
126
|
-
end
|
|
151
|
+
establish_connection_pool(tenant) unless connection_pool_registered?
|
|
127
152
|
|
|
128
153
|
Apartment.connection.enable_query_cache! if query_cache_enabled
|
|
129
154
|
|
|
@@ -143,6 +168,25 @@ module Apartment
|
|
|
143
168
|
|
|
144
169
|
protected
|
|
145
170
|
|
|
171
|
+
ESTABLISH_CONNECTION_LOCK = Mutex.new
|
|
172
|
+
|
|
173
|
+
def connection_pool_registered?
|
|
174
|
+
!ActiveRecord::Base.connection_handler.retrieve_connection_pool(
|
|
175
|
+
ActiveRecord::Base.connection_specification_name,
|
|
176
|
+
role: ActiveRecord::Base.current_role,
|
|
177
|
+
shard: ActiveRecord::Base.current_shard,
|
|
178
|
+
).nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def establish_connection_pool(tenant)
|
|
182
|
+
ESTABLISH_CONNECTION_LOCK.synchronize do
|
|
183
|
+
next if connection_pool_registered?
|
|
184
|
+
|
|
185
|
+
Apartment.establish_connection multi_tenantify(tenant, false)
|
|
186
|
+
Apartment.connection.verify!
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
146
190
|
def persistent_schemas
|
|
147
191
|
map_to_schemas(super)
|
|
148
192
|
end
|
|
@@ -319,30 +363,10 @@ ActiveSupport.on_load(:action_cable) do
|
|
|
319
363
|
end
|
|
320
364
|
end
|
|
321
365
|
|
|
322
|
-
# Lazily switch any worker threads to the correct tenant when they are working
|
|
323
|
-
#
|
|
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
|
-
|
|
366
|
+
# Lazily switch any worker threads to the correct tenant when they are working (see
|
|
367
|
+
# Apartment::Tenant.with_lazy_tenant above for why this is lazy, not an active switch).
|
|
328
368
|
ActionCable::Server::Worker.set_callback :work, :around do |_, blk|
|
|
329
|
-
|
|
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
|
|
369
|
+
Apartment::Tenant.with_lazy_tenant(connection.tenant, &blk)
|
|
346
370
|
end
|
|
347
371
|
|
|
348
372
|
ActiveSupport.on_load(:active_record) do
|
|
@@ -358,6 +382,17 @@ ActiveSupport.on_load(:action_cable) do
|
|
|
358
382
|
end
|
|
359
383
|
end
|
|
360
384
|
|
|
385
|
+
# cable_room runs a Room's own code -- message handlers, startup, shutdown, snapshotting -- on a
|
|
386
|
+
# Host worker thread, never inside ActionCable's `:work` callback (its worker pool deliberately
|
|
387
|
+
# isn't an ActionCable::Server::Worker subclass, so it doesn't share that callback chain).
|
|
388
|
+
# `Room#around_work` is its equivalent seam; hook it the same lazy way, on every Room, via
|
|
389
|
+
# CableRoom::Room::Base so every app Room picks it up.
|
|
390
|
+
if defined?(CableRoom)
|
|
391
|
+
CableRoom::Room::Base.around_work do |room, blk|
|
|
392
|
+
Apartment::Tenant.with_lazy_tenant(room.tenant, &blk)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
361
396
|
if defined?(Delayed)
|
|
362
397
|
module PandaPal::Plugins
|
|
363
398
|
class ApartmentDelayedJobsPlugin < ::Delayed::Plugin
|
|
@@ -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
|
data/lib/panda_pal/version.rb
CHANGED
|
@@ -53,6 +53,72 @@ RSpec.describe PandaPal::Organization, type: :model do
|
|
|
53
53
|
expect(PandaPal::ApiCall.connection.exec_query("SELECT current_database()").pluck("current_database")[0]).to eq("panda_pal_test2")
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
context "connection pool lifecycle" do
|
|
57
|
+
def alt_pool
|
|
58
|
+
ActiveRecord::Base.connection_handler.retrieve_connection_pool(
|
|
59
|
+
ActiveRecord::Base.connection_specification_name,
|
|
60
|
+
role: ActiveRecord::Base.current_role,
|
|
61
|
+
shard: "apt:alt",
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def count_establish_connection_calls
|
|
66
|
+
calls = Queue.new
|
|
67
|
+
allow(Apartment).to receive(:establish_connection).and_wrap_original do |original, *args, **kwargs|
|
|
68
|
+
calls << true
|
|
69
|
+
original.call(*args, **kwargs)
|
|
70
|
+
end
|
|
71
|
+
yield
|
|
72
|
+
calls.size
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def switch_in_threads(tenant, count)
|
|
76
|
+
start = Queue.new
|
|
77
|
+
threads = count.times.map do
|
|
78
|
+
Thread.new do
|
|
79
|
+
start.pop
|
|
80
|
+
Apartment::Tenant.switch(tenant) { PandaPal::ApiCall.count }
|
|
81
|
+
ensure
|
|
82
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
count.times { start << true }
|
|
86
|
+
threads.map(&:value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "reuses an existing shard pool whose connections have all been closed" do
|
|
90
|
+
org2.switch_tenant { PandaPal::ApiCall.count }
|
|
91
|
+
pool = alt_pool
|
|
92
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
|
93
|
+
pool.disconnect!
|
|
94
|
+
|
|
95
|
+
calls = count_establish_connection_calls do
|
|
96
|
+
org2.switch_tenant { expect(PandaPal::ApiCall.count).to eq(0) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
expect(calls).to eq(0)
|
|
100
|
+
expect(alt_pool).to equal(pool)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "establishes a missing shard pool once when many threads need it at the same time" do
|
|
104
|
+
org2.switch_tenant { PandaPal::ApiCall.count }
|
|
105
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
|
106
|
+
ActiveRecord::Base.connection_handler.remove_connection_pool(
|
|
107
|
+
ActiveRecord::Base.connection_specification_name,
|
|
108
|
+
role: ActiveRecord::Base.current_role,
|
|
109
|
+
shard: "apt:alt",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
results = nil
|
|
113
|
+
calls = count_establish_connection_calls do
|
|
114
|
+
results = switch_in_threads("alt:on_alt", 8)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
expect(results).to eq([0] * 8)
|
|
118
|
+
expect(calls).to eq(1)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
56
122
|
# load_async is a Rails 7.0+ feature.
|
|
57
123
|
context "load_async", skip: (ActiveRecord::Relation.method_defined?(:load_async) ? false : "requires Rails 7.0+ load_async") do
|
|
58
124
|
it "works across shards" do
|
|
@@ -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
|
|
@@ -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.17.
|
|
4
|
+
version: 5.17.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Instructure CustomDev
|
|
@@ -210,6 +210,7 @@ files:
|
|
|
210
210
|
- spec/internal/db/schema.rb
|
|
211
211
|
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
212
212
|
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
213
|
+
- spec/lib/panda_pal/helpers/session_replacement_spec.rb
|
|
213
214
|
- spec/models/panda_pal/api_call_spec.rb
|
|
214
215
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
215
216
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
@@ -254,6 +255,7 @@ test_files:
|
|
|
254
255
|
- spec/internal/db/schema.rb
|
|
255
256
|
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
256
257
|
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
258
|
+
- spec/lib/panda_pal/helpers/session_replacement_spec.rb
|
|
257
259
|
- spec/models/panda_pal/api_call_spec.rb
|
|
258
260
|
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
259
261
|
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|