cli
1# frozen_string_literal: true
2
3RSpec.describe Scim::Kit::Cli::App do
4 let(:base_url) { FFaker::Internet.uri('https') }
5 let(:resource_types) { [{ id: 'User', name: 'User', endpoint: '/Users' }] }
6
7 def app(options = {})
8 described_class.new([], { 'url' => base_url }.merge(options))
9 end
10
11 before do
12 stub_request(:get, "#{base_url}/ResourceTypes")
13 .to_return(status: 200, body: resource_types.to_json)
14 end
15
16 shared_examples 'a resource-type-resolving command' do |call|
17 context 'when the resource type is unknown' do
18 it 'reports the unknown resource type' do
19 expect { exit_status { call.call(app, 'Nope') } }.to output(/Nope/).to_stderr
20 end
21
22 it 'exits 1' do
23 allow($stderr).to receive(:puts)
24
25 expect(exit_status { call.call(app, 'Nope') }).to eq(1)
26 end
27 end
28
29 context 'when fetching ResourceTypes fails' do
30 before do
31 stub_request(:get, "#{base_url}/ResourceTypes")
32 .to_return(status: 500, body: { detail: 'boom' }.to_json)
33 end
34
35 it 'reports the failure' do
36 expect { exit_status { call.call(app, 'User') } }
37 .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
38 end
39 end
40
41 context 'when the matched resource type has no endpoint' do
42 let(:resource_types) { [{ id: 'User', name: 'User' }] }
43
44 it 'reports a MissingEndpoint error' do
45 expect { exit_status { call.call(app, 'User') } }
46 .to output(/User/).to_stderr
47 end
48
49 it 'exits 1' do
50 allow($stderr).to receive(:puts)
51
52 expect(exit_status { call.call(app, 'User') }).to eq(1)
53 end
54 end
55 end
56
57 describe '#discover' do
58 let(:service_provider_configuration) { { patch: { supported: true } } }
59 let(:schemas) { [{ id: 'User', name: 'User' }] }
60
61 context 'when every request succeeds' do
62 before do
63 stub_request(:get, "#{base_url}/ServiceProviderConfig")
64 .to_return(status: 200, body: service_provider_configuration.to_json)
65 stub_request(:get, "#{base_url}/Schemas")
66 .to_return(status: 200, body: schemas.to_json)
67 end
68
69 let(:expected_output) do
70 JSON.pretty_generate(
71 service_provider_configuration: service_provider_configuration,
72 schemas: schemas,
73 resource_types: resource_types
74 )
75 end
76
77 it 'prints the combined configuration as pretty json' do
78 expect { exit_status { app.discover } }
79 .to output("#{expected_output}\n").to_stdout
80 end
81
82 it 'exits 0' do
83 allow($stdout).to receive(:puts)
84
85 expect(exit_status { app.discover }).to eq(0)
86 end
87 end
88
89 context 'when the ServiceProviderConfig request fails' do
90 before do
91 stub_request(:get, "#{base_url}/ServiceProviderConfig")
92 .to_return(status: 500, body: { detail: 'boom' }.to_json)
93 end
94
95 it 'reports the failure without requesting Schemas' do
96 allow($stderr).to receive(:puts)
97 exit_status { app.discover }
98
99 expect(a_request(:get, "#{base_url}/Schemas")).not_to have_been_made
100 end
101
102 it 'exits 1' do
103 allow($stderr).to receive(:puts)
104
105 expect(exit_status { app.discover }).to eq(1)
106 end
107 end
108
109 context 'when --validate is set and every document is valid' do
110 let(:service_provider_configuration) do
111 {
112 schemas: [
113 'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'
114 ],
115 patch: { supported: true },
116 bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
117 filter: { supported: true, maxResults: 200 },
118 changePassword: { supported: false },
119 sort: { supported: false },
120 etag: { supported: false },
121 authenticationSchemes: [
122 {
123 type: 'httpbasic', name: 'HTTP Basic',
124 description: 'basic auth'
125 }
126 ]
127 }
128 end
129 let(:schemas) do
130 {
131 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
132 totalResults: 1,
133 Resources: [
134 {
135 id: 'urn:ietf:params:scim:schemas:core:2.0:User',
136 schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
137 attributes: [
138 { name: 'userName', type: 'string', required: true }
139 ]
140 }
141 ]
142 }
143 end
144 let(:resource_types) do
145 {
146 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
147 totalResults: 1,
148 Resources: [
149 {
150 schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
151 name: 'User', endpoint: '/Users',
152 schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
153 }
154 ]
155 }
156 end
157
158 before do
159 stub_request(:get, "#{base_url}/ServiceProviderConfig").to_return(
160 status: 200, body: service_provider_configuration.to_json
161 )
162 stub_request(:get, "#{base_url}/Schemas")
163 .to_return(status: 200, body: schemas.to_json)
164 stub_request(:get, "#{base_url}/ResourceTypes")
165 .to_return(status: 200, body: resource_types.to_json)
166 end
167
168 it 'exits 0' do
169 allow($stdout).to receive(:puts)
170 instance = app('validate' => true)
171
172 expect(exit_status { instance.discover }).to eq(0)
173 end
174 end
175
176 context 'when --validate is set and a collection is a bare array' do
177 let(:schemas) do
178 [
179 {
180 id: 'urn:ietf:params:scim:schemas:core:2.0:User',
181 schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
182 attributes: [{ name: 'userName', type: 'string' }]
183 }
184 ]
185 end
186
187 before do
188 stub_request(:get, "#{base_url}/ServiceProviderConfig").to_return(
189 status: 200, body: service_provider_configuration.to_json
190 )
191 stub_request(:get, "#{base_url}/Schemas")
192 .to_return(status: 200, body: schemas.to_json)
193 end
194
195 it 'flags the bare array as non-compliant with ListResponse format' do
196 allow($stdout).to receive(:puts)
197 instance = app('validate' => true)
198
199 expect { exit_status { instance.discover } }
200 .to output(/root is not of type: object/).to_stderr
201 end
202 end
203
204 context 'when --validate is set and a document is invalid' do
205 let(:service_provider_configuration) { { patch: { supported: true } } }
206 let(:schemas) { [{ id: 'User', name: 'User' }] }
207
208 before do
209 stub_request(:get, "#{base_url}/ServiceProviderConfig").to_return(
210 status: 200, body: service_provider_configuration.to_json
211 )
212 stub_request(:get, "#{base_url}/Schemas")
213 .to_return(status: 200, body: schemas.to_json)
214 end
215
216 it 'prints validation errors to stderr' do
217 allow($stdout).to receive(:puts)
218 instance = app('validate' => true)
219
220 expect { exit_status { instance.discover } }
221 .to output(/validation_errors/).to_stderr
222 end
223
224 it 'exits 1' do
225 allow($stdout).to receive(:puts)
226 allow($stderr).to receive(:puts)
227 instance = app('validate' => true)
228
229 expect(exit_status { instance.discover }).to eq(1)
230 end
231 end
232 end
233
234 describe '#list' do
235 include_examples 'a resource-type-resolving command', ->(a, type) { a.list(type) }
236
237 context 'when the resource type and the list request both succeed' do
238 let(:list_response) { { totalResults: 1, Resources: [{ id: '1' }] } }
239 let(:instance) do
240 app(
241 'filter' => 'userName eq "bjensen"',
242 'start_index' => 2,
243 'count' => 10,
244 'sort_by' => 'userName',
245 'sort_order' => 'ascending',
246 'attributes' => 'userName,emails'
247 )
248 end
249
250 before do
251 stub_request(:get, "#{base_url}/Users")
252 .with(
253 query: {
254 'filter' => 'userName eq "bjensen"',
255 'startIndex' => '2',
256 'count' => '10',
257 'sortBy' => 'userName',
258 'sortOrder' => 'ascending',
259 'attributes' => 'userName,emails'
260 }
261 )
262 .to_return(status: 200, body: list_response.to_json)
263 end
264
265 it 'prints the list response as pretty json' do
266 expect { exit_status { instance.list('User') } }
267 .to output("#{JSON.pretty_generate(list_response)}\n").to_stdout
268 end
269
270 it 'exits 0' do
271 allow($stdout).to receive(:puts)
272
273 expect(exit_status { instance.list('User') }).to eq(0)
274 end
275 end
276
277 context 'when the list request fails' do
278 before do
279 stub_request(:get, "#{base_url}/Users")
280 .to_return(status: 404, body: { detail: 'not found' }.to_json)
281 end
282
283 it 'reports the failure' do
284 expect { exit_status { app.list('User') } }
285 .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
286 end
287
288 it 'exits 1' do
289 allow($stderr).to receive(:puts)
290
291 expect(exit_status { app.list('User') }).to eq(1)
292 end
293 end
294
295 context 'when --validate is set' do
296 let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
297 let(:resource_types) do
298 [{ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }]
299 end
300 let(:schemas_response) do
301 [
302 {
303 id: core_urn,
304 attributes: [
305 { name: 'userName', type: 'string', required: true }
306 ]
307 }
308 ]
309 end
310
311 before do
312 stub_request(:get, "#{base_url}/Schemas")
313 .to_return(status: 200, body: schemas_response.to_json)
314 end
315
316 context 'when the list response is valid' do
317 let(:list_response) do
318 {
319 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
320 totalResults: 1,
321 Resources: [
322 { schemas: [core_urn], id: '1', userName: 'bjensen' }
323 ]
324 }
325 end
326
327 before do
328 stub_request(:get, "#{base_url}/Users")
329 .to_return(status: 200, body: list_response.to_json)
330 end
331
332 it 'exits 0' do
333 allow($stdout).to receive(:puts)
334 instance = app('validate' => true)
335
336 expect(exit_status { instance.list('User') }).to eq(0)
337 end
338 end
339
340 context 'when --attributes narrows the response' do
341 before do
342 stub_request(:get, "#{base_url}/Users?attributes=id")
343 .to_return(
344 status: 200,
345 body: {
346 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
347 totalResults: 1,
348 Resources: [{ schemas: [core_urn], id: '1' }]
349 }.to_json
350 )
351 end
352
353 it 'exits 0 without demanding attributes the server was not asked for' do
354 allow($stdout).to receive(:puts)
355 instance = app('validate' => true, 'attributes' => 'id')
356
357 expect(exit_status { instance.list('User') }).to eq(0)
358 end
359 end
360
361 context 'when the resource type declares an extension /Schemas omits' do
362 let(:extension_urn) { 'urn:vendor:2.0:Thing' }
363 let(:resource_types) do
364 [{
365 id: 'User', name: 'User', endpoint: '/Users', schema: core_urn,
366 schemaExtensions: [{ schema: extension_urn, required: true }]
367 }]
368 end
369
370 before do
371 stub_request(:get, "#{base_url}/Users").to_return(
372 status: 200,
373 body: {
374 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
375 totalResults: 1,
376 Resources: [
377 { schemas: [core_urn], id: '1', userName: 'bjensen' }
378 ]
379 }.to_json
380 )
381 end
382
383 it 'warns about the undeclared extension' do
384 allow($stdout).to receive(:puts)
385 instance = app('validate' => true)
386
387 expect { exit_status { instance.list('User') } }
388 .to output(/#{Regexp.escape(extension_urn)}/).to_stderr
389 end
390
391 it 'still exits 0' do
392 allow($stdout).to receive(:puts)
393 allow($stderr).to receive(:puts)
394 instance = app('validate' => true)
395
396 expect(exit_status { instance.list('User') }).to eq(0)
397 end
398 end
399
400 context 'when a resource in the list response is invalid' do
401 let(:list_response) do
402 {
403 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
404 totalResults: 1, Resources: [{ id: '1', userName: 42 }]
405 }
406 end
407
408 before do
409 stub_request(:get, "#{base_url}/Users")
410 .to_return(status: 200, body: list_response.to_json)
411 end
412
413 it 'prints validation errors to stderr' do
414 allow($stdout).to receive(:puts)
415 instance = app('validate' => true)
416
417 expect { exit_status { instance.list('User') } }
418 .to output(/validation_errors/).to_stderr
419 end
420
421 it 'exits 1' do
422 allow($stdout).to receive(:puts)
423 allow($stderr).to receive(:puts)
424 instance = app('validate' => true)
425
426 expect(exit_status { instance.list('User') }).to eq(1)
427 end
428 end
429
430 context 'when the resource type has no resolvable schema' do
431 let(:resource_types) do
432 [
433 { id: 'User', name: 'User', endpoint: '/Users',
434 schema: 'urn:example:Unresolvable' }
435 ]
436 end
437
438 before do
439 stub_request(:get, "#{base_url}/Users")
440 .to_return(status: 200, body: { totalResults: 0 }.to_json)
441 end
442
443 it 'warns that it could not validate' do
444 allow($stdout).to receive(:puts)
445 instance = app('validate' => true)
446
447 expect { exit_status { instance.list('User') } }
448 .to output(/no schema found/).to_stderr
449 end
450
451 it 'exits 1 rather than reporting an unvalidated success' do
452 allow($stdout).to receive(:puts)
453 allow($stderr).to receive(:puts)
454 instance = app('validate' => true)
455
456 expect(exit_status { instance.list('User') }).to eq(1)
457 end
458 end
459 end
460 end
461
462 describe '#get' do
463 include_examples 'a resource-type-resolving command', ->(a, type) { a.get(type, '123') }
464
465 context 'when the resource type and the get request both succeed' do
466 let(:resource) { { id: '123', userName: 'bjensen' } }
467 let(:instance) { app('attributes' => 'userName,emails') }
468
469 before do
470 stub_request(:get, "#{base_url}/Users/123")
471 .with(query: { 'attributes' => 'userName,emails' })
472 .to_return(status: 200, body: resource.to_json)
473 end
474
475 it 'prints the resource as pretty json' do
476 expect { exit_status { instance.get('User', '123') } }
477 .to output("#{JSON.pretty_generate(resource)}\n").to_stdout
478 end
479
480 it 'exits 0' do
481 allow($stdout).to receive(:puts)
482
483 expect(exit_status { instance.get('User', '123') }).to eq(0)
484 end
485 end
486
487 context 'when the id needs escaping' do
488 it 'escapes a space rather than raising URI::InvalidURIError' do
489 stub = stub_request(:get, "#{base_url}/Users/mo%20khan")
490 .to_return(status: 200, body: {}.to_json)
491 allow($stdout).to receive(:puts)
492
493 exit_status { app.get('User', 'mo khan') }
494
495 expect(stub).to have_been_requested
496 end
497
498 it 'escapes separators so an id cannot traverse the endpoint' do
499 stub = stub_request(:get, "#{base_url}/Users/..%2Fadmin%23x%3Fy")
500 .to_return(status: 200, body: {}.to_json)
501 allow($stdout).to receive(:puts)
502
503 exit_status { app.get('User', '../admin#x?y') }
504
505 expect(stub).to have_been_requested
506 end
507 end
508
509 context 'when the get request fails' do
510 before do
511 stub_request(:get, "#{base_url}/Users/123")
512 .to_return(status: 404, body: { detail: 'not found' }.to_json)
513 end
514
515 it 'reports the failure' do
516 expect { exit_status { app.get('User', '123') } }
517 .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
518 end
519
520 it 'exits 1' do
521 allow($stderr).to receive(:puts)
522
523 expect(exit_status { app.get('User', '123') }).to eq(1)
524 end
525 end
526
527 context 'when --validate is set' do
528 let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
529 let(:resource_types) do
530 [{ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }]
531 end
532 let(:schemas_response) do
533 [
534 {
535 id: core_urn,
536 attributes: [
537 { name: 'userName', type: 'string', required: true }
538 ]
539 }
540 ]
541 end
542
543 before do
544 stub_request(:get, "#{base_url}/Schemas")
545 .to_return(status: 200, body: schemas_response.to_json)
546 end
547
548 context 'when the resource is valid' do
549 before do
550 stub_request(:get, "#{base_url}/Users/123").to_return(
551 status: 200,
552 body: {
553 schemas: [core_urn], id: '123', userName: 'bjensen'
554 }.to_json
555 )
556 end
557
558 it 'exits 0' do
559 allow($stdout).to receive(:puts)
560 instance = app('validate' => true)
561
562 expect(exit_status { instance.get('User', '123') }).to eq(0)
563 end
564 end
565
566 context 'when the resource is invalid' do
567 before do
568 stub_request(:get, "#{base_url}/Users/123").to_return(
569 status: 200, body: { id: '123', userName: 42 }.to_json
570 )
571 end
572
573 it 'prints validation errors to stderr' do
574 allow($stdout).to receive(:puts)
575 instance = app('validate' => true)
576
577 expect { exit_status { instance.get('User', '123') } }
578 .to output(/validation_errors/).to_stderr
579 end
580
581 it 'exits 1' do
582 allow($stdout).to receive(:puts)
583 allow($stderr).to receive(:puts)
584 instance = app('validate' => true)
585
586 expect(exit_status { instance.get('User', '123') }).to eq(1)
587 end
588 end
589 end
590 end
591
592 describe 'header parsing' do
593 before do
594 stub_request(:get, "#{base_url}/ResourceTypes")
595 .with(headers: { 'Authorization' => 'Bearer xyz', 'X-Test' => 'value' })
596 .to_return(status: 200, body: resource_types.to_json)
597 stub_request(:get, "#{base_url}/Users").to_return(status: 200, body: '{}')
598 end
599
600 it 'sends repeated --header flags as request headers' do
601 allow($stdout).to receive(:puts)
602 instance = app('header' => ['Authorization: Bearer xyz', 'X-Test: value'])
603
604 exit_status { instance.list('User') }
605
606 expect(a_request(:get, "#{base_url}/Users")).to have_been_made
607 end
608 end
609
610 describe 'CLI argv parsing via .start' do
611 around do |example|
612 original = ENV.fetch('SCIM_KIT_URL', nil)
613 example.run
614 ENV['SCIM_KIT_URL'] = original
615 end
616
617 before do
618 ENV.delete('SCIM_KIT_URL')
619 stub_request(:get, "#{base_url}/Users").to_return(status: 200, body: '{}')
620 end
621
622 it 'exits 1 with a usage message when RESOURCE_TYPE is missing' do
623 allow($stderr).to receive(:puts)
624
625 expect { exit_status { described_class.start(['list', '--url', base_url]) } }
626 .to output(/no arguments/).to_stderr
627 end
628
629 it 'exits 1 when --url is missing entirely' do
630 allow($stderr).to receive(:puts)
631
632 status = exit_status { described_class.start(%w[list User]) }
633
634 expect(status).to eq(1)
635 end
636
637 it 'reads --url from SCIM_KIT_URL when --url is omitted' do
638 ENV['SCIM_KIT_URL'] = base_url
639 allow($stdout).to receive(:puts)
640
641 status = exit_status { described_class.start(%w[list User]) }
642
643 expect(status).to eq(0)
644 end
645
646 context 'with repeated --header flags' do
647 let(:headers) { { 'Authorization' => 'Bearer xyz', 'X-Test' => 'value' } }
648 let(:argv) do
649 ['list', 'User', '--url', base_url,
650 '--header', 'Authorization: Bearer xyz',
651 '--header', 'X-Test: value']
652 end
653
654 before do
655 allow($stdout).to receive(:puts)
656 stub_request(:get, "#{base_url}/Users")
657 .with(headers: headers).to_return(status: 200, body: '{}')
658 end
659
660 it 'sends every header on the request' do
661 exit_status { described_class.start(argv) }
662
663 expect(
664 a_request(:get, "#{base_url}/Users").with(headers: headers)
665 ).to have_been_made
666 end
667 end
668
669 it 'exits 1 with a usage message for a malformed --header' do
670 allow($stderr).to receive(:puts)
671 argv = ['list', 'User', '--url', base_url, '--header', 'BearerXYZ']
672
673 expect { exit_status { described_class.start(argv) } }
674 .to output(/malformed --header/).to_stderr
675 end
676 end
677end