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,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
|