panda_pal 5.18.2 → 5.19.0.beta2
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/README.md +39 -0
- data/app/controllers/panda_pal/lti_v1_p0_controller.rb +11 -4
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +17 -8
- data/app/lib/lti_xml/base_platform.rb +32 -18
- data/app/lib/lti_xml/bridge_platform.rb +2 -2
- data/app/lib/panda_pal/launch_url_helpers.rb +21 -14
- data/app/models/panda_pal/lti_registration.rb +41 -0
- data/app/models/panda_pal/organization.rb +2 -0
- data/app/models/panda_pal/platform/canvas.rb +88 -28
- data/app/models/panda_pal/session.rb +19 -1
- data/config/initializers/apartment.rb +1 -1
- data/db/migrate/20261006000000_create_panda_pal_lti_registrations.rb +14 -0
- data/db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb +5 -0
- data/lib/panda_pal/helpers/controller_helper.rb +20 -1
- data/lib/panda_pal/helpers/route_helper.rb +8 -1
- data/lib/panda_pal/helpers/session_replacement.rb +2 -2
- data/lib/panda_pal/version.rb +1 -1
- data/lib/panda_pal.rb +82 -10
- data/panda_pal.gemspec +2 -1
- metadata +19 -50
- data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -110
- data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
- data/spec/core/apartment_multidb_spec.rb +0 -166
- data/spec/factories/panda_pal_organizations.rb +0 -9
- data/spec/factories/panda_pal_sessions.rb +0 -6
- data/spec/internal/app/models/account.rb +0 -7
- data/spec/internal/app/models/admin.rb +0 -6
- data/spec/internal/config/database.yml +0 -18
- data/spec/internal/config/routes.rb +0 -5
- data/spec/internal/config/storage.yml +0 -3
- data/spec/internal/db/schema.rb +0 -30
- data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
- data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
- data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
- data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
- data/spec/models/panda_pal/api_call_spec.rb +0 -30
- data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
- data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
- data/spec/models/panda_pal/organization_spec.rb +0 -50
- data/spec/models/panda_pal/platform_spec.rb +0 -349
- data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
- data/spec/models/panda_pal/session_spec.rb +0 -202
- data/spec/spec_helper.rb +0 -158
|
@@ -1,349 +0,0 @@
|
|
|
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
|
-
describe 'LTI Advantage service tokens' do
|
|
152
|
-
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
|
|
153
|
-
let(:platform) { Platform::Canvas.new(iss: 'https://canvas.instructure.com') }
|
|
154
|
-
let(:grant_url) { 'https://sso.canvaslms.com/login/oauth2/token' }
|
|
155
|
-
let(:client_id) { '10000000000042' }
|
|
156
|
-
let(:score_scope) { LtiConstants::AgsScopes::SCORE }
|
|
157
|
-
let(:cache) { ActiveSupport::Cache::MemoryStore.new }
|
|
158
|
-
|
|
159
|
-
before do
|
|
160
|
-
PandaPal.lti_private_key = rsa_key
|
|
161
|
-
PandaPal.jwk = nil
|
|
162
|
-
allow(Rails).to receive(:cache).and_return(cache)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
after do
|
|
166
|
-
PandaPal.lti_private_key = nil
|
|
167
|
-
PandaPal.jwk = nil
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def http_response(code, body = '', headers = {})
|
|
171
|
-
double('HTTParty::Response', code: code, success?: (200..299).cover?(code), body: body, headers: headers)
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def token_response(token, expires_in: 3600)
|
|
175
|
-
http_response(200, { access_token: token, token_type: 'Bearer', expires_in: expires_in }.to_json)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
describe '#service_access_token' do
|
|
179
|
-
it 'posts a client_credentials grant with an RS256 client assertion signed by the tool key' do
|
|
180
|
-
captured = nil
|
|
181
|
-
allow(HTTParty).to receive(:post) do |url, **options|
|
|
182
|
-
captured = { url: url, options: options }
|
|
183
|
-
token_response('service-token')
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
token = platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
187
|
-
|
|
188
|
-
expect(token).to eq('service-token')
|
|
189
|
-
expect(captured[:url]).to eq(grant_url)
|
|
190
|
-
|
|
191
|
-
body = captured[:options][:body]
|
|
192
|
-
expect(body[:grant_type]).to eq('client_credentials')
|
|
193
|
-
expect(body[:client_assertion_type]).to eq('urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
|
|
194
|
-
expect(body[:scope]).to eq(score_scope)
|
|
195
|
-
|
|
196
|
-
payload, header = JWT.decode(body[:client_assertion], rsa_key.public_key, true, algorithm: 'RS256')
|
|
197
|
-
expect(header['alg']).to eq('RS256')
|
|
198
|
-
expect(header['kid']).to eq(PandaPal.jwk.kid)
|
|
199
|
-
expect(payload['iss']).to eq(client_id)
|
|
200
|
-
expect(payload['sub']).to eq(client_id)
|
|
201
|
-
expect(payload['aud']).to eq(grant_url)
|
|
202
|
-
expect(payload['jti']).to be_present
|
|
203
|
-
expect(payload['exp'] - payload['iat']).to eq(300)
|
|
204
|
-
expect(payload['iat']).to be_within(5).of(Time.now.to_i)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
it 'joins several scopes with spaces' do
|
|
208
|
-
expect(HTTParty).to receive(:post).with(grant_url, hash_including(
|
|
209
|
-
body: hash_including(scope: [LtiConstants::AgsScopes::LINEITEM_READONLY, score_scope].sort.join(' '))
|
|
210
|
-
)).and_return(token_response('service-token'))
|
|
211
|
-
|
|
212
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope, LtiConstants::AgsScopes::LINEITEM_READONLY])
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
it 'bounds the request with timeouts' do
|
|
216
|
-
expect(HTTParty).to receive(:post).with(grant_url, hash_including(open_timeout: 5, read_timeout: 15))
|
|
217
|
-
.and_return(token_response('service-token'))
|
|
218
|
-
|
|
219
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
it 'reuses the cached token for the same client and scopes' do
|
|
223
|
-
expect(HTTParty).to receive(:post).once.and_return(token_response('service-token'))
|
|
224
|
-
|
|
225
|
-
2.times { expect(platform.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('service-token') }
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
it 'requests a separate token for a different scope set' do
|
|
229
|
-
expect(HTTParty).to receive(:post).twice.and_return(token_response('score-token'), token_response('lineitem-token'))
|
|
230
|
-
|
|
231
|
-
expect(platform.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('score-token')
|
|
232
|
-
expect(platform.service_access_token(client_id: client_id, scopes: [LtiConstants::AgsScopes::LINEITEM])).to eq('lineitem-token')
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
it 'caches the token for its expires_in less a minute' do
|
|
236
|
-
allow(HTTParty).to receive(:post).and_return(token_response('service-token', expires_in: 600))
|
|
237
|
-
expect(cache).to receive(:write).with(kind_of(String), 'service-token', expires_in: 540.seconds).and_call_original
|
|
238
|
-
|
|
239
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
it 'does not cache the key under the token or the assertion' do
|
|
243
|
-
allow(HTTParty).to receive(:post).and_return(token_response('service-token'))
|
|
244
|
-
expect(cache).to receive(:write).with(%r{\Apanda_pal/lti_service_tokens/[0-9a-f]{64}\z}, anything, anything)
|
|
245
|
-
|
|
246
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
it 'fetches a new token when forced' do
|
|
250
|
-
expect(HTTParty).to receive(:post).twice.and_return(token_response('first'), token_response('second'))
|
|
251
|
-
|
|
252
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
253
|
-
expect(platform.service_access_token(client_id: client_id, scopes: [score_scope], force: true)).to eq('second')
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
it 'raises ServiceTokenError with the status, without echoing the response body, on a non-2xx answer' do
|
|
257
|
-
allow(HTTParty).to receive(:post).and_return(http_response(400, '{"error":"invalid_client","assertion":"secret-assertion"}'))
|
|
258
|
-
|
|
259
|
-
expect {
|
|
260
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
261
|
-
}.to raise_error(Platform::ServiceTokenError) { |error|
|
|
262
|
-
expect(error.status).to eq(400)
|
|
263
|
-
expect(error.message).to eq('token request answered 400')
|
|
264
|
-
expect(error).to be_a(PandaPal::LtiServiceError)
|
|
265
|
-
}
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
it 'raises ServiceTokenError when the response has no access_token' do
|
|
269
|
-
allow(HTTParty).to receive(:post).and_return(http_response(200, '{"token_type":"Bearer"}'))
|
|
270
|
-
|
|
271
|
-
expect {
|
|
272
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
273
|
-
}.to raise_error(Platform::ServiceTokenError, /no access_token/)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
it 'wraps network errors in LtiServiceError' do
|
|
277
|
-
allow(HTTParty).to receive(:post).and_raise(Net::OpenTimeout)
|
|
278
|
-
|
|
279
|
-
expect {
|
|
280
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
281
|
-
}.to raise_error(PandaPal::LtiServiceError, 'token request failed: Net::OpenTimeout')
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
it 'uses the Generic platform grant_url' do
|
|
285
|
-
generic = Platform::Generic.from_urls('https://lms.example.com', grant: '/oauth/token')
|
|
286
|
-
expect(HTTParty).to receive(:post).with('https://lms.example.com/oauth/token', anything).and_return(token_response('generic-token'))
|
|
287
|
-
|
|
288
|
-
expect(generic.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('generic-token')
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
describe '#expire_service_access_token' do
|
|
293
|
-
it 'drops the cached token so the next call requests a new one' do
|
|
294
|
-
expect(HTTParty).to receive(:post).twice.and_return(token_response('first'), token_response('second'))
|
|
295
|
-
|
|
296
|
-
platform.service_access_token(client_id: client_id, scopes: [score_scope])
|
|
297
|
-
platform.expire_service_access_token(client_id: client_id, scopes: [score_scope])
|
|
298
|
-
|
|
299
|
-
expect(platform.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('second')
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
describe '#service_request' do
|
|
304
|
-
let(:url) { 'https://canvas.example.com/api/lti/courses/1/line_items/2/scores' }
|
|
305
|
-
|
|
306
|
-
it 'sends the bearer token with the given headers and body' do
|
|
307
|
-
allow(HTTParty).to receive(:post).with(grant_url, anything).and_return(token_response('service-token'))
|
|
308
|
-
expect(HTTParty).to receive(:post).with(url, hash_including(
|
|
309
|
-
headers: { 'Content-Type' => 'application/json', 'Authorization' => 'Bearer service-token' },
|
|
310
|
-
body: '{}',
|
|
311
|
-
open_timeout: 5,
|
|
312
|
-
)).and_return(http_response(200))
|
|
313
|
-
|
|
314
|
-
response = platform.service_request(:post, url, client_id: client_id, scopes: [score_scope],
|
|
315
|
-
headers: { 'Content-Type' => 'application/json' }, body: '{}')
|
|
316
|
-
|
|
317
|
-
expect(response.code).to eq(200)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
it 'retries once with a new token when a cached token is refused with a 401' do
|
|
321
|
-
cache.write(platform.send(:service_token_cache_key, client_id, [score_scope]), 'stale-token')
|
|
322
|
-
expect(HTTParty).to receive(:post).with(grant_url, anything).once.and_return(token_response('fresh-token'))
|
|
323
|
-
expect(HTTParty).to receive(:post).with(url, hash_including(headers: { 'Authorization' => 'Bearer stale-token' })).and_return(http_response(401))
|
|
324
|
-
expect(HTTParty).to receive(:post).with(url, hash_including(headers: { 'Authorization' => 'Bearer fresh-token' })).and_return(http_response(200))
|
|
325
|
-
|
|
326
|
-
response = platform.service_request(:post, url, client_id: client_id, scopes: [score_scope])
|
|
327
|
-
|
|
328
|
-
expect(response.code).to eq(200)
|
|
329
|
-
expect(cache.read(platform.send(:service_token_cache_key, client_id, [score_scope]))).to eq('fresh-token')
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
it 'does not retry when a freshly issued token is refused' do
|
|
333
|
-
expect(HTTParty).to receive(:post).with(grant_url, anything).once.and_return(token_response('fresh-token'))
|
|
334
|
-
expect(HTTParty).to receive(:post).with(url, anything).once.and_return(http_response(401))
|
|
335
|
-
|
|
336
|
-
expect(platform.service_request(:post, url, client_id: client_id, scopes: [score_scope]).code).to eq(401)
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
it 'returns the second 401 when the refreshed token is refused too' do
|
|
340
|
-
cache.write(platform.send(:service_token_cache_key, client_id, [score_scope]), 'stale-token')
|
|
341
|
-
allow(HTTParty).to receive(:post).with(grant_url, anything).and_return(token_response('fresh-token'))
|
|
342
|
-
expect(HTTParty).to receive(:post).with(url, anything).twice.and_return(http_response(401))
|
|
343
|
-
|
|
344
|
-
expect(platform.service_request(:post, url, client_id: client_id, scopes: [score_scope]).code).to eq(401)
|
|
345
|
-
end
|
|
346
|
-
end
|
|
347
|
-
end
|
|
348
|
-
end
|
|
349
|
-
end
|
|
@@ -1,182 +0,0 @@
|
|
|
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
|