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