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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +39 -0
  3. data/app/controllers/panda_pal/lti_v1_p0_controller.rb +11 -4
  4. data/app/controllers/panda_pal/lti_v1_p3_controller.rb +17 -8
  5. data/app/lib/lti_xml/base_platform.rb +32 -18
  6. data/app/lib/lti_xml/bridge_platform.rb +2 -2
  7. data/app/lib/panda_pal/launch_url_helpers.rb +21 -14
  8. data/app/models/panda_pal/lti_registration.rb +41 -0
  9. data/app/models/panda_pal/organization.rb +2 -0
  10. data/app/models/panda_pal/platform/canvas.rb +88 -28
  11. data/app/models/panda_pal/session.rb +19 -1
  12. data/config/initializers/apartment.rb +1 -1
  13. data/db/migrate/20261006000000_create_panda_pal_lti_registrations.rb +14 -0
  14. data/db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb +5 -0
  15. data/lib/panda_pal/helpers/controller_helper.rb +20 -1
  16. data/lib/panda_pal/helpers/route_helper.rb +8 -1
  17. data/lib/panda_pal/helpers/session_replacement.rb +2 -2
  18. data/lib/panda_pal/version.rb +1 -1
  19. data/lib/panda_pal.rb +82 -10
  20. data/panda_pal.gemspec +2 -1
  21. metadata +19 -50
  22. data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
  23. data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -110
  24. data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
  25. data/spec/core/apartment_multidb_spec.rb +0 -166
  26. data/spec/factories/panda_pal_organizations.rb +0 -9
  27. data/spec/factories/panda_pal_sessions.rb +0 -6
  28. data/spec/internal/app/models/account.rb +0 -7
  29. data/spec/internal/app/models/admin.rb +0 -6
  30. data/spec/internal/config/database.yml +0 -18
  31. data/spec/internal/config/routes.rb +0 -5
  32. data/spec/internal/config/storage.yml +0 -3
  33. data/spec/internal/db/schema.rb +0 -30
  34. data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
  35. data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
  36. data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
  37. data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
  38. data/spec/models/panda_pal/api_call_spec.rb +0 -30
  39. data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
  40. data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
  41. data/spec/models/panda_pal/organization_spec.rb +0 -50
  42. data/spec/models/panda_pal/platform_spec.rb +0 -349
  43. data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
  44. data/spec/models/panda_pal/session_spec.rb +0 -202
  45. data/spec/spec_helper.rb +0 -158
@@ -1,385 +0,0 @@
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
@@ -1,77 +0,0 @@
1
- require 'spec_helper'
2
-
3
- RSpec.describe PandaPal::SecureHeaders do
4
- subject(:config) { SecureHeaders::Configuration.new }
5
-
6
- describe '.apply_defaults' do
7
- before { described_class.apply_defaults(config) }
8
-
9
- it 'returns the same configuration object it was given' do
10
- expect(described_class.apply_defaults(config)).to equal(config)
11
- end
12
-
13
- it 'relaxes the default cookie samesite restriction for LTI iframes' do
14
- expect(config.cookies).to eq(samesite: { none: true })
15
- end
16
-
17
- it 'does not mark cookies secure outside of production' do
18
- expect(config.cookies[:secure]).to be_nil
19
- end
20
-
21
- it 'allows the app to be embedded in LTI iframes' do
22
- expect(config.x_frame_options).to eq('ALLOWALL')
23
- end
24
-
25
- it 'sets baseline browser protection headers' do
26
- expect(config.x_content_type_options).to eq('nosniff')
27
- expect(config.x_xss_protection).to eq('1; mode=block')
28
- expect(config.referrer_policy).to eq(%w(origin-when-cross-origin strict-origin-when-cross-origin))
29
- end
30
-
31
- it 'restricts default_src, connect_src, and script_src to self' do
32
- expect(config.csp[:default_src]).to include("'self'")
33
- expect(config.csp[:connect_src]).to include("'self'")
34
- expect(config.csp[:script_src]).to include("'self'")
35
- end
36
-
37
- it 'allows inline styles and Google Fonts for CSS-in-JS libraries' do
38
- expect(config.csp[:style_src]).to include("'self'", "'unsafe-inline'", 'blob:', 'https://fonts.googleapis.com')
39
- expect(config.csp[:font_src]).to include("'self'", 'data:', 'https://fonts.gstatic.com')
40
- end
41
-
42
- it 'does not add development-only CSP allowances outside of development' do
43
- expect(config.csp[:script_src]).not_to include("'unsafe-eval'")
44
- expect(config.csp[:connect_src]).not_to include('http://localhost:3035')
45
- end
46
- end
47
-
48
- describe '.apply_defaults in production' do
49
- around do |example|
50
- original_env = Rails.env
51
- begin
52
- Rails.env = 'production'
53
- example.run
54
- ensure
55
- Rails.env = original_env
56
- end
57
- end
58
-
59
- it 'marks cookies as secure' do
60
- described_class.apply_defaults(config)
61
- expect(config.cookies[:secure]).to be true
62
- end
63
- end
64
-
65
- describe '.csp_entry' do
66
- it 'merges values into the directive without duplicating existing entries' do
67
- config.csp[:default_src] = ["'self'"]
68
- described_class.instance_variable_set(:@config, config)
69
-
70
- described_class.send(:csp_entry, :default_src, "'self'", 'https://example.com')
71
-
72
- expect(config.csp[:default_src]).to eq(["'self'", 'https://example.com'])
73
- ensure
74
- described_class.instance_variable_set(:@config, nil)
75
- end
76
- end
77
- end
@@ -1,72 +0,0 @@
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
@@ -1,30 +0,0 @@
1
- require 'spec_helper'
2
-
3
- module PandaPal
4
- RSpec.describe ApiCall, type: :model do
5
- let!(:subject) { ApiCall.create!(logic: '"#{p[:foo]}_bar"') }
6
- let!(:jwt) { subject.jwt_token }
7
-
8
- describe ".from_jwt" do
9
- it "finds an ApiCall from a JWT" do
10
- expect(ApiCall.from_jwt(jwt)).to eq subject
11
- end
12
-
13
- it "fails if the JWT is invalid" do
14
- expect{ ApiCall.from_jwt("really.bad.jwt") }.to raise_error(JWT::DecodeError)
15
- end
16
- end
17
-
18
- describe "#call" do
19
- it "calls the function with parameters" do
20
- expect(subject.call({ foo: 'FOO' })).to eq 'FOO_bar'
21
- end
22
- end
23
-
24
- describe "#call_url" do
25
- it "generates a URL with the expected placholders" do
26
- expect(subject.call_url).to eq "/panda_pal/call_logic?foo=TODO&token=#{jwt}"
27
- end
28
- end
29
- end
30
- end