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