panda_pal 5.17.2 → 5.18.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.
data/panda_pal.gemspec CHANGED
@@ -17,7 +17,7 @@ Gem::Specification.new do |s|
17
17
  s.summary = "LTI mountable engine"
18
18
  s.license = "MIT"
19
19
 
20
- s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "panda_pal.gemspec"]
20
+ s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "AGS.md", "panda_pal.gemspec"]
21
21
  s.test_files = Dir["spec/**/*"].reject{|f| f =~ /\/log\//}
22
22
 
23
23
  s.add_dependency "rails", ">= 4.2"
@@ -75,4 +75,34 @@ RSpec.describe "PandaPal::LtiV1P3Controller", type: :request do
75
75
  expect(hidden_fields['nonce']).not_to be_blank
76
76
  end
77
77
  end
78
+
79
+ describe 'GET /v1p3/config' do
80
+ before { PandaPal.lti_environments = { domain: 'tool.example.com' } }
81
+
82
+ after do
83
+ PandaPal.lti_environments = {}
84
+ PandaPal.lti_scopes = []
85
+ end
86
+
87
+ it 'publishes no scopes by default' do
88
+ get "/panda_pal/v1p3/config"
89
+
90
+ expect(response).to have_http_status(:ok)
91
+ expect(JSON.parse(response.body)['scopes']).to eq([])
92
+ end
93
+
94
+ it 'publishes the configured PandaPal.lti_scopes' do
95
+ PandaPal.lti_scopes = [
96
+ PandaPal::LtiConstants::AgsScopes::SCORE,
97
+ PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
98
+ ]
99
+
100
+ get "/panda_pal/v1p3/config"
101
+
102
+ expect(JSON.parse(response.body)['scopes']).to eq([
103
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/score',
104
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
105
+ ])
106
+ end
107
+ end
78
108
  end
@@ -0,0 +1,385 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe PandaPal::AgsClient do
4
+ let(:platform) { PandaPal::Platform::Canvas.new(iss: 'https://canvas.instructure.com') }
5
+ let(:client_id) { '10000000000042' }
6
+ let(:lineitem) { 'https://canvas.example.com/api/lti/courses/1/line_items/7' }
7
+ let(:lineitems) { 'https://canvas.example.com/api/lti/courses/1/line_items' }
8
+ let(:scopes) { PandaPal::LtiConstants::AgsScopes::ALL }
9
+ let(:user_id) { 'launch-sub-uuid' }
10
+
11
+ subject(:client) do
12
+ described_class.new(platform: platform, client_id: client_id, scopes: scopes,
13
+ lineitem: lineitem, lineitems: lineitems, user_id: user_id)
14
+ end
15
+
16
+ def http_response(code, body = '', headers = {})
17
+ double('HTTParty::Response', code: code, success?: (200..299).cover?(code), body: body, headers: headers)
18
+ end
19
+
20
+ def expect_service_request(method, url, scope, response, **options)
21
+ expect(platform).to receive(:service_request)
22
+ .with(method, url, client_id: client_id, scopes: [scope], **options)
23
+ .and_return(response)
24
+ end
25
+
26
+ describe '.from_launch' do
27
+ let(:launch) do
28
+ {
29
+ 'sub' => user_id,
30
+ 'aud' => client_id,
31
+ PandaPal::LtiConstants::Ags::ENDPOINT => {
32
+ 'scope' => [PandaPal::LtiConstants::AgsScopes::SCORE],
33
+ 'lineitem' => lineitem,
34
+ 'lineitems' => lineitems,
35
+ },
36
+ }
37
+ end
38
+
39
+ it 'reads the AGS endpoint claim, the sub and the client_id' do
40
+ built = described_class.from_launch(launch, platform: platform)
41
+
42
+ expect(built.platform).to eq(platform)
43
+ expect(built.client_id).to eq(client_id)
44
+ expect(built.user_id).to eq(user_id)
45
+ expect(built.lineitem).to eq(lineitem)
46
+ expect(built.lineitems).to eq(lineitems)
47
+ expect(built.scopes).to eq([PandaPal::LtiConstants::AgsScopes::SCORE])
48
+ end
49
+
50
+ it 'prefers azp over an aud array' do
51
+ built = described_class.from_launch(launch.merge('aud' => ['other', client_id], 'azp' => client_id), platform: platform)
52
+
53
+ expect(built.client_id).to eq(client_id)
54
+ end
55
+
56
+ it 'grants nothing when the launch has no AGS claim' do
57
+ built = described_class.from_launch(launch.except(PandaPal::LtiConstants::Ags::ENDPOINT), platform: platform)
58
+
59
+ expect(built.scopes).to eq([])
60
+ expect(built.lineitem).to be_nil
61
+ expect { built.post_score(score_given: 1, score_maximum: 1) }.to raise_error(described_class::NotGranted)
62
+ end
63
+ end
64
+
65
+ describe '.for_session and Session#ags_client' do
66
+ let(:organization) { create(:panda_pal_organization, key: "#{client_id}/deployment-1") }
67
+ let(:session) do
68
+ PandaPal::Session.new(panda_pal_organization: organization, data: {
69
+ lti_platform: platform.serialize,
70
+ launch_params: {
71
+ 'sub' => user_id,
72
+ 'aud' => client_id,
73
+ PandaPal::LtiConstants::Ags::ENDPOINT => { 'scope' => scopes, 'lineitem' => lineitem, 'lineitems' => lineitems },
74
+ },
75
+ })
76
+ end
77
+
78
+ it 'builds a client from the session launch_params and lti_platform' do
79
+ built = session.ags_client
80
+
81
+ expect(built).to be_a(described_class)
82
+ expect(built.platform).to be_a(PandaPal::Platform::Canvas)
83
+ expect(built.platform.grant_url).to eq(platform.grant_url)
84
+ expect(built.client_id).to eq(client_id)
85
+ expect(built.lineitem).to eq(lineitem)
86
+ expect(built.user_id).to eq(user_id)
87
+ expect(built.scopes).to eq(scopes)
88
+ expect(session.ags_client).to be(built)
89
+ end
90
+
91
+ it 'falls back to the client_id half of the organization key when the launch has no aud' do
92
+ session.data[:launch_params].delete('aud')
93
+
94
+ expect(described_class.for_session(session).client_id).to eq(client_id)
95
+ end
96
+
97
+ it 'is exposed to controllers through PandaPal::AgsHelpers' do
98
+ host = Class.new do
99
+ include PandaPal::AgsHelpers
100
+
101
+ attr_reader :current_session
102
+
103
+ def initialize(session)
104
+ @current_session = session
105
+ end
106
+ end
107
+
108
+ expect(host.new(session).ags_client).to be(session.ags_client)
109
+ end
110
+ end
111
+
112
+ describe '#post_score' do
113
+ let(:scores_url) { "#{lineitem}/scores" }
114
+
115
+ it 'posts the AGS score media type to the lineitem scores endpoint for the launch sub' do
116
+ captured = nil
117
+ expect(platform).to receive(:service_request) do |method, url, **options|
118
+ captured = { method: method, url: url, options: options }
119
+ http_response(200, '{"resultUrl":"https://canvas.example.com/results/1"}')
120
+ end
121
+
122
+ timestamp = Time.utc(2026, 9, 30, 12, 0, 0, 123_456)
123
+ result = client.post_score(score_given: 8, score_maximum: 10, timestamp: timestamp)
124
+
125
+ expect(result).to eq('resultUrl' => 'https://canvas.example.com/results/1')
126
+ expect(captured[:method]).to eq(:post)
127
+ expect(captured[:url]).to eq(scores_url)
128
+ expect(captured[:options][:client_id]).to eq(client_id)
129
+ expect(captured[:options][:scopes]).to eq([PandaPal::LtiConstants::AgsScopes::SCORE])
130
+ expect(captured[:options][:headers]).to eq('Content-Type' => 'application/vnd.ims.lis.v1.score+json')
131
+ expect(JSON.parse(captured[:options][:body])).to eq(
132
+ 'userId' => user_id,
133
+ 'scoreGiven' => 8,
134
+ 'scoreMaximum' => 10,
135
+ 'activityProgress' => 'Completed',
136
+ 'gradingProgress' => 'FullyGraded',
137
+ 'timestamp' => '2026-09-30T12:00:00.123Z',
138
+ )
139
+ end
140
+
141
+ it 'includes a comment, overrides and extra fields when given' do
142
+ other_user = SecureRandom.uuid
143
+ body = nil
144
+ allow(platform).to receive(:service_request) { |*, **options| body = JSON.parse(options[:body]); http_response(204) }
145
+
146
+ result = client.post_score(score_given: 1, score_maximum: 1, comment: 'Well done', user_id: other_user,
147
+ activity_progress: 'Submitted', grading_progress: 'Pending',
148
+ 'https://canvas.instructure.com/lti/submission': { new_submission: true })
149
+
150
+ expect(result).to be_nil
151
+ expect(body).to include(
152
+ 'userId' => other_user,
153
+ 'comment' => 'Well done',
154
+ 'activityProgress' => 'Submitted',
155
+ 'gradingProgress' => 'Pending',
156
+ 'https://canvas.instructure.com/lti/submission' => { 'new_submission' => true },
157
+ )
158
+ end
159
+
160
+ it 'omits scoreGiven and scoreMaximum when no score is given' do
161
+ body = nil
162
+ allow(platform).to receive(:service_request) { |*, **options| body = JSON.parse(options[:body]); http_response(200, '{}') }
163
+
164
+ client.post_score(activity_progress: 'Started', grading_progress: 'NotReady')
165
+
166
+ expect(body.keys).not_to include('scoreGiven', 'scoreMaximum')
167
+ end
168
+
169
+ it 'keeps a lineitem query string after the scores segment' do
170
+ with_query = described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id,
171
+ lineitem: 'https://canvas.example.com/api/lti/courses/1/line_items/7?type_id=abc&x=1')
172
+ expect(platform).to receive(:service_request)
173
+ .with(:post, 'https://canvas.example.com/api/lti/courses/1/line_items/7/scores?type_id=abc&x=1', anything)
174
+ .and_return(http_response(200, '{}'))
175
+
176
+ with_query.post_score(score_given: 1, score_maximum: 1)
177
+ end
178
+
179
+ it 'does not double a trailing slash' do
180
+ expect(platform).to receive(:service_request).with(:post, scores_url, anything).and_return(http_response(200, '{}'))
181
+
182
+ described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id, lineitem: "#{lineitem}/")
183
+ .post_score(score_given: 1, score_maximum: 1)
184
+ end
185
+
186
+ it 'refuses before any request when the score scope was not granted' do
187
+ expect(platform).not_to receive(:service_request)
188
+
189
+ limited = described_class.new(platform: platform, client_id: client_id, lineitem: lineitem, user_id: user_id,
190
+ scopes: [PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY])
191
+
192
+ expect { limited.post_score(score_given: 1, score_maximum: 1) }
193
+ .to raise_error(described_class::NotGranted, %r{scope/score})
194
+ end
195
+
196
+ it 'refuses before any request when the launch carried no lineitem' do
197
+ expect(platform).not_to receive(:service_request)
198
+
199
+ no_lineitem = described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id)
200
+
201
+ expect { no_lineitem.post_score(score_given: 1, score_maximum: 1) }
202
+ .to raise_error(described_class::NotGranted, /no AGS lineitem/)
203
+ end
204
+
205
+ it 'refuses a lineitem that is not an http(s) URL' do
206
+ expect(platform).not_to receive(:service_request)
207
+
208
+ bad = described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id, lineitem: 'javascript:alert(1)')
209
+
210
+ expect { bad.post_score(score_given: 1, score_maximum: 1) }.to raise_error(described_class::Error, /http\(s\)/)
211
+ end
212
+
213
+ it 'refuses when there is no user id' do
214
+ expect(platform).not_to receive(:service_request)
215
+
216
+ expect { client.post_score(score_given: 1, score_maximum: 1, user_id: nil) }
217
+ .to raise_error(described_class::NotGranted, /userId/)
218
+ end
219
+
220
+ it 'requires score_maximum alongside score_given' do
221
+ expect { client.post_score(score_given: 1) }.to raise_error(ArgumentError)
222
+ end
223
+
224
+ it 'refuses when there is no platform' do
225
+ no_platform = described_class.new(platform: nil, client_id: client_id, scopes: scopes, user_id: user_id, lineitem: lineitem)
226
+
227
+ expect { no_platform.post_score(score_given: 1, score_maximum: 1) }.to raise_error(described_class::Error, /platform/)
228
+ end
229
+
230
+ it 'raises RequestFailed with the status on a non-2xx answer' do
231
+ allow(platform).to receive(:service_request).and_return(http_response(422, '{"errors":"bad"}'))
232
+
233
+ expect { client.post_score(score_given: 1, score_maximum: 1) }
234
+ .to raise_error(described_class::RequestFailed) { |error|
235
+ expect(error.status).to eq(422)
236
+ expect(error).to be_a(PandaPal::LtiServiceError)
237
+ }
238
+ end
239
+ end
240
+
241
+ describe '#line_items' do
242
+ let(:container) { PandaPal::LtiConstants::AgsMediaTypes::LINEITEM_CONTAINER }
243
+ let(:readonly) { PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY }
244
+
245
+ it 'lists line items with the container media type and the read-only scope' do
246
+ expect_service_request(:get, lineitems, readonly,
247
+ http_response(200, [{ 'id' => lineitem, 'label' => 'Quiz' }].to_json),
248
+ headers: { 'Accept' => container })
249
+
250
+ expect(client.line_items).to eq([{ 'id' => lineitem, 'label' => 'Quiz' }])
251
+ end
252
+
253
+ it 'adds filters to the query string' do
254
+ expect_service_request(:get, "#{lineitems}?resource_link_id=rl-1&tag=grade", readonly,
255
+ http_response(200, '[]'), headers: { 'Accept' => container })
256
+
257
+ expect(client.line_items(resource_link_id: 'rl-1', tag: 'grade', resource_id: nil)).to eq([])
258
+ end
259
+
260
+ it 'follows rel="next" links on the same origin' do
261
+ page_two = "#{lineitems}?page=2"
262
+ expect_service_request(:get, lineitems, readonly,
263
+ http_response(200, '[{"id":"a"}]', { 'link' => %(<#{page_two}>; rel="next", <#{lineitems}?page=9>; rel="last") }),
264
+ headers: { 'Accept' => container })
265
+ expect_service_request(:get, page_two, readonly, http_response(200, '[{"id":"b"}]'), headers: { 'Accept' => container })
266
+
267
+ expect(client.line_items.map { |item| item['id'] }).to eq(%w[a b])
268
+ end
269
+
270
+ it 'does not follow a next link to another origin' do
271
+ expect_service_request(:get, lineitems, readonly,
272
+ http_response(200, '[{"id":"a"}]', { 'link' => '<https://attacker.example.com/steal>; rel="next"' }),
273
+ headers: { 'Accept' => container })
274
+
275
+ expect(client.line_items.map { |item| item['id'] }).to eq(%w[a])
276
+ end
277
+
278
+ it 'uses the lineitem scope when only that was granted' do
279
+ full = described_class.new(platform: platform, client_id: client_id, lineitems: lineitems,
280
+ scopes: [PandaPal::LtiConstants::AgsScopes::LINEITEM])
281
+ expect_service_request(:get, lineitems, PandaPal::LtiConstants::AgsScopes::LINEITEM,
282
+ http_response(200, '[]'), headers: { 'Accept' => container })
283
+
284
+ full.line_items
285
+ end
286
+
287
+ it 'refuses when no line item scope was granted' do
288
+ expect(platform).not_to receive(:service_request)
289
+
290
+ score_only = described_class.new(platform: platform, client_id: client_id, lineitems: lineitems,
291
+ scopes: [PandaPal::LtiConstants::AgsScopes::SCORE])
292
+
293
+ expect { score_only.line_items }.to raise_error(described_class::NotGranted, /lineitem/)
294
+ end
295
+
296
+ it 'refuses when the launch carried no lineitems URL' do
297
+ expect(platform).not_to receive(:service_request)
298
+
299
+ no_container = described_class.new(platform: platform, client_id: client_id, scopes: scopes)
300
+
301
+ expect { no_container.line_items }.to raise_error(described_class::NotGranted, /lineitems URL/)
302
+ end
303
+ end
304
+
305
+ describe '#line_item' do
306
+ it 'gets the launch lineitem with the lineitem media type' do
307
+ expect_service_request(:get, lineitem, PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
308
+ http_response(200, { 'id' => lineitem, 'scoreMaximum' => 10 }.to_json),
309
+ headers: { 'Accept' => PandaPal::LtiConstants::AgsMediaTypes::LINEITEM })
310
+
311
+ expect(client.line_item).to eq('id' => lineitem, 'scoreMaximum' => 10)
312
+ end
313
+
314
+ it 'gets an explicit lineitem URL' do
315
+ other = "#{lineitems}/99"
316
+ expect_service_request(:get, other, PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
317
+ http_response(200, '{"id":"99"}'), headers: { 'Accept' => PandaPal::LtiConstants::AgsMediaTypes::LINEITEM })
318
+
319
+ expect(client.line_item(other)).to eq('id' => '99')
320
+ end
321
+
322
+ it 'raises RequestFailed when the body is not JSON' do
323
+ allow(platform).to receive(:service_request).and_return(http_response(200, '<html>'))
324
+
325
+ expect { client.line_item }.to raise_error(described_class::RequestFailed, /not JSON/)
326
+ end
327
+ end
328
+
329
+ describe '#results' do
330
+ let(:results_url) { "#{lineitem}/results" }
331
+ let(:container) { PandaPal::LtiConstants::AgsMediaTypes::RESULT_CONTAINER }
332
+ let(:result_scope) { PandaPal::LtiConstants::AgsScopes::RESULT_READONLY }
333
+
334
+ it 'reads the lineitem results with the result container media type' do
335
+ expect_service_request(:get, results_url, result_scope,
336
+ http_response(200, [{ 'userId' => user_id, 'resultScore' => 8 }].to_json), headers: { 'Accept' => container })
337
+
338
+ expect(client.results).to eq([{ 'userId' => user_id, 'resultScore' => 8 }])
339
+ end
340
+
341
+ it 'filters by user_id and limit, keeping an existing query string' do
342
+ with_query = described_class.new(platform: platform, client_id: client_id, scopes: scopes,
343
+ lineitem: "#{lineitem}?type_id=abc")
344
+ expect_service_request(:get, "#{results_url}?type_id=abc&user_id=#{user_id}&limit=5", result_scope,
345
+ http_response(200, '[]'), headers: { 'Accept' => container })
346
+
347
+ expect(with_query.results(user_id: user_id, limit: 5)).to eq([])
348
+ end
349
+
350
+ it 'refuses when the result scope was not granted' do
351
+ expect(platform).not_to receive(:service_request)
352
+
353
+ score_only = described_class.new(platform: platform, client_id: client_id, lineitem: lineitem,
354
+ scopes: [PandaPal::LtiConstants::AgsScopes::SCORE])
355
+
356
+ expect { score_only.results }.to raise_error(described_class::NotGranted, /result\.readonly/)
357
+ end
358
+ end
359
+
360
+ describe 'end to end through the platform token' do
361
+ let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
362
+
363
+ before do
364
+ PandaPal.lti_private_key = rsa_key
365
+ PandaPal.jwk = nil
366
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
367
+ end
368
+
369
+ after do
370
+ PandaPal.lti_private_key = nil
371
+ PandaPal.jwk = nil
372
+ end
373
+
374
+ it 'requests only the score scope and posts with the issued bearer token' do
375
+ expect(HTTParty).to receive(:post).with(platform.grant_url, hash_including(
376
+ body: hash_including(grant_type: 'client_credentials', scope: PandaPal::LtiConstants::AgsScopes::SCORE)
377
+ )).and_return(http_response(200, '{"access_token":"issued","expires_in":3600}'))
378
+ expect(HTTParty).to receive(:post).with("#{lineitem}/scores", hash_including(
379
+ headers: { 'Content-Type' => 'application/vnd.ims.lis.v1.score+json', 'Authorization' => 'Bearer issued' }
380
+ )).and_return(http_response(200, '{}'))
381
+
382
+ client.post_score(score_given: 1, score_maximum: 1)
383
+ end
384
+ end
385
+ end
@@ -148,5 +148,202 @@ module PandaPal
148
148
  expect(restored.jwks_url).to eq(platform.jwks_url)
149
149
  end
150
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
151
348
  end
152
349
  end
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.2
4
+ version: 5.18.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -133,6 +133,7 @@ executables: []
133
133
  extensions: []
134
134
  extra_rdoc_files: []
135
135
  files:
136
+ - AGS.md
136
137
  - MIT-LICENSE
137
138
  - README.md
138
139
  - Rakefile
@@ -151,6 +152,8 @@ files:
151
152
  - app/lib/lti_xml/base_platform.rb
152
153
  - app/lib/lti_xml/bridge_platform.rb
153
154
  - app/lib/lti_xml/canvas_platform.rb
155
+ - app/lib/panda_pal/ags_client.rb
156
+ - app/lib/panda_pal/ags_helpers.rb
154
157
  - app/lib/panda_pal/deep_linking_helpers.rb
155
158
  - app/lib/panda_pal/launch_url_helpers.rb
156
159
  - app/lib/panda_pal/lti_jwt_validator.rb
@@ -209,6 +212,7 @@ files:
209
212
  - spec/internal/config/storage.yml
210
213
  - spec/internal/db/schema.rb
211
214
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
215
+ - spec/lib/panda_pal/ags_client_spec.rb
212
216
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
213
217
  - spec/lib/panda_pal/helpers/session_replacement_spec.rb
214
218
  - spec/models/panda_pal/api_call_spec.rb
@@ -254,6 +258,7 @@ test_files:
254
258
  - spec/internal/config/storage.yml
255
259
  - spec/internal/db/schema.rb
256
260
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
261
+ - spec/lib/panda_pal/ags_client_spec.rb
257
262
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
258
263
  - spec/lib/panda_pal/helpers/session_replacement_spec.rb
259
264
  - spec/models/panda_pal/api_call_spec.rb