haveapi 0.27.3 → 0.28.0

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/haveapi.gemspec +1 -1
  4. data/lib/haveapi/action.rb +125 -36
  5. data/lib/haveapi/actions/paginable.rb +3 -1
  6. data/lib/haveapi/authentication/basic/provider.rb +2 -0
  7. data/lib/haveapi/authentication/chain.rb +11 -7
  8. data/lib/haveapi/authentication/oauth2/config.rb +25 -3
  9. data/lib/haveapi/authentication/oauth2/provider.rb +92 -11
  10. data/lib/haveapi/authentication/oauth2/revoke_endpoint.rb +44 -3
  11. data/lib/haveapi/authentication/token/provider.rb +53 -15
  12. data/lib/haveapi/authorization.rb +42 -18
  13. data/lib/haveapi/client_examples/php_client.rb +1 -1
  14. data/lib/haveapi/client_examples/ruby_client.rb +1 -1
  15. data/lib/haveapi/context.rb +10 -4
  16. data/lib/haveapi/example.rb +15 -16
  17. data/lib/haveapi/extensions/action_exceptions.rb +6 -6
  18. data/lib/haveapi/model_adapters/active_record.rb +140 -68
  19. data/lib/haveapi/model_adapters/hash.rb +1 -1
  20. data/lib/haveapi/parameters/resource.rb +35 -3
  21. data/lib/haveapi/parameters/typed.rb +26 -7
  22. data/lib/haveapi/params.rb +27 -8
  23. data/lib/haveapi/resource.rb +4 -1
  24. data/lib/haveapi/resources/action_state.rb +8 -1
  25. data/lib/haveapi/route.rb +2 -2
  26. data/lib/haveapi/server.rb +137 -45
  27. data/lib/haveapi/validator.rb +2 -2
  28. data/lib/haveapi/validator_chain.rb +1 -0
  29. data/lib/haveapi/validators/confirmation.rb +1 -0
  30. data/lib/haveapi/validators/format.rb +6 -2
  31. data/lib/haveapi/validators/length.rb +2 -0
  32. data/lib/haveapi/validators/numericality.rb +2 -0
  33. data/lib/haveapi/validators/presence.rb +1 -1
  34. data/lib/haveapi/version.rb +1 -1
  35. data/lib/haveapi/views/version_page/client_auth.erb +1 -1
  36. data/lib/haveapi/views/version_page/client_example.erb +3 -3
  37. data/lib/haveapi/views/version_page/client_init.erb +1 -1
  38. data/lib/haveapi/views/version_page.erb +2 -2
  39. data/lib/haveapi/views/version_sidebar.erb +4 -2
  40. data/spec/action/authorize_spec.rb +99 -0
  41. data/spec/action/runtime_spec.rb +426 -0
  42. data/spec/action_state_spec.rb +52 -0
  43. data/spec/authentication/basic_spec.rb +29 -0
  44. data/spec/authentication/oauth2_spec.rb +329 -0
  45. data/spec/authentication/token_spec.rb +195 -0
  46. data/spec/authentication/token_version_routes_spec.rb +164 -0
  47. data/spec/authorization_spec.rb +66 -0
  48. data/spec/documentation/auth_filtering_spec.rb +195 -1
  49. data/spec/documentation/current_user_html_escaping_spec.rb +47 -0
  50. data/spec/documentation/examples_spec.rb +97 -0
  51. data/spec/documentation/host_html_escaping_spec.rb +41 -0
  52. data/spec/documentation_spec.rb +13 -0
  53. data/spec/extensions/action_exceptions_spec.rb +30 -0
  54. data/spec/model_adapters/active_record_spec.rb +406 -1
  55. data/spec/parameters/typed_spec.rb +42 -0
  56. data/spec/params_spec.rb +41 -0
  57. data/spec/server/integration_spec.rb +90 -0
  58. data/spec/validator_chain_spec.rb +39 -0
  59. data/spec/validators/confirmation_spec.rb +14 -0
  60. data/spec/validators/format_spec.rb +7 -0
  61. data/spec/validators/length_spec.rb +6 -0
  62. data/spec/validators/numericality_spec.rb +7 -0
  63. data/spec/validators/presence_spec.rb +2 -0
  64. data/test_support/client_test_api.rb +28 -0
  65. metadata +8 -4
  66. data/shell.nix +0 -20
@@ -93,6 +93,27 @@ describe HaveAPI::Extensions::ActionExceptions do
93
93
  )
94
94
  end
95
95
 
96
+ def with_action_exceptions_without_handlers
97
+ hooks = HaveAPI::Hooks.hooks
98
+ action_hooks = hooks[HaveAPI::Action][:exec_exception]
99
+ original_listeners = action_hooks[:listeners].dup
100
+ original_exceptions =
101
+ HaveAPI::Extensions::ActionExceptions.instance_variable_get(:@exceptions)
102
+
103
+ if HaveAPI::Extensions::ActionExceptions.instance_variable_defined?(:@exceptions)
104
+ HaveAPI::Extensions::ActionExceptions.remove_instance_variable(:@exceptions)
105
+ end
106
+ HaveAPI::Extensions::ActionExceptions.enabled(app.settings.api_server)
107
+
108
+ yield
109
+ ensure
110
+ action_hooks[:listeners] = original_listeners
111
+ HaveAPI::Extensions::ActionExceptions.instance_variable_set(
112
+ :@exceptions,
113
+ original_exceptions
114
+ )
115
+ end
116
+
96
117
  def map_exception(klass, status)
97
118
  HaveAPI::Extensions::ActionExceptions.rescue(klass) do |ret, e|
98
119
  ret[:status] = false
@@ -151,6 +172,15 @@ describe HaveAPI::Extensions::ActionExceptions do
151
172
  end
152
173
  end
153
174
 
175
+ it 'keeps exceptions in a safe envelope when no handlers are registered' do
176
+ with_action_exceptions_without_handlers do
177
+ call_test_action(:raise_runtime)
178
+
179
+ expect_failed_json(500)
180
+ expect(api_response.message).not_to be_empty
181
+ end
182
+ end
183
+
154
184
  it 'does not interfere with successful responses' do
155
185
  with_action_exceptions do
156
186
  call_test_action(:ok)
@@ -29,6 +29,24 @@ module ARAdapterSpec
29
29
  validates :score, numericality: { equal_to: 7 }
30
30
  validates :name, presence: true
31
31
  end
32
+
33
+ class FilteredMember < ActiveRecord::Base
34
+ self.table_name = 'users'
35
+
36
+ belongs_to :group, class_name: 'ARAdapterSpec::Group', optional: true
37
+ end
38
+
39
+ class HiddenAccount < ActiveRecord::Base
40
+ self.table_name = 'hidden_accounts'
41
+ end
42
+
43
+ class Invoice < ActiveRecord::Base
44
+ belongs_to :hidden_account, class_name: 'ARAdapterSpec::HiddenAccount'
45
+ end
46
+
47
+ class StringAccount < ActiveRecord::Base
48
+ self.primary_key = 'uuid'
49
+ end
32
50
  end
33
51
 
34
52
  describe HaveAPI::ModelAdapters::ActiveRecord do
@@ -94,12 +112,77 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
94
112
  resource env_resource
95
113
  end
96
114
 
115
+ def prepare
116
+ group = self.class.model.find(params['group_id'])
117
+ error!('access denied') if group.note == 'PRIVATE_GROUP_NOTE'
118
+ end
119
+
97
120
  def exec
98
121
  self.class.model.find(params['group_id'])
99
122
  end
100
123
  end
101
124
  end
102
125
 
126
+ filtered_group_resource = define_resource(:FilteredGroup) do
127
+ version 1
128
+ auth false
129
+ model ARAdapterSpec::Group
130
+
131
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
132
+ authorize do
133
+ output blacklist: [:note]
134
+ allow
135
+ end
136
+
137
+ output(:object_list) do
138
+ integer :id
139
+ string :label
140
+ string :note
141
+ end
142
+
143
+ def exec
144
+ self.class.model.order(id: :asc).to_a
145
+ end
146
+ end
147
+
148
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
149
+ authorize do
150
+ output blacklist: [:note]
151
+ allow
152
+ end
153
+
154
+ output(:object) do
155
+ integer :id
156
+ string :label
157
+ string :note
158
+ end
159
+
160
+ def exec
161
+ self.class.model.find(params['filtered_group_id'])
162
+ end
163
+ end
164
+ end
165
+
166
+ define_resource(:FilteredMember) do
167
+ version 1
168
+ auth false
169
+ model ARAdapterSpec::FilteredMember
170
+
171
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
172
+ authorize { allow }
173
+
174
+ output(:object) do
175
+ integer :id
176
+ string :name
177
+ resource filtered_group_resource, name: :group
178
+ end
179
+
180
+ def exec
181
+ with_includes(self.class.model.where(id: params['filtered_member_id'])).take!
182
+ end
183
+ end
184
+ end
185
+
103
186
  define_resource(:User) do
104
187
  version 1
105
188
  auth false
@@ -147,6 +230,42 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
147
230
  end
148
231
  end
149
232
 
233
+ define_action(:PublicShow, superclass: HaveAPI::Actions::Default::Show) do
234
+ route 'public/{user_id}/show'
235
+
236
+ authorize do
237
+ output whitelist: [:name]
238
+ allow
239
+ end
240
+
241
+ output(:object) do
242
+ integer :id
243
+ string :name
244
+ end
245
+
246
+ def exec
247
+ self.class.model.find(params['user_id'])
248
+ end
249
+ end
250
+
251
+ define_action(:PublicIndex, superclass: HaveAPI::Actions::Default::Index) do
252
+ route 'public/list'
253
+
254
+ authorize do
255
+ output whitelist: [:name]
256
+ allow
257
+ end
258
+
259
+ output(:object_list) do
260
+ integer :id
261
+ string :name
262
+ end
263
+
264
+ def exec
265
+ self.class.model.order(id: :asc).to_a
266
+ end
267
+ end
268
+
150
269
  define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
151
270
  authorize { allow }
152
271
 
@@ -164,6 +283,75 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
164
283
  end
165
284
  end
166
285
  end
286
+
287
+ hidden_account_resource = define_resource(:HiddenAccount) do
288
+ version 1
289
+ auth false
290
+ model ARAdapterSpec::HiddenAccount
291
+
292
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
293
+ authorize { deny }
294
+
295
+ output(:object_list) do
296
+ integer :id
297
+ string :label
298
+ end
299
+
300
+ def exec
301
+ self.class.model.order(id: :asc).to_a
302
+ end
303
+ end
304
+
305
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
306
+ authorize { deny }
307
+
308
+ output(:object) do
309
+ integer :id
310
+ string :label
311
+ string :private_reference
312
+ end
313
+
314
+ def exec
315
+ self.class.model.find(params['hidden_account_id'])
316
+ end
317
+ end
318
+ end
319
+
320
+ define_resource(:Invoice) do
321
+ version 1
322
+ auth false
323
+ model ARAdapterSpec::Invoice
324
+
325
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
326
+ authorize { allow }
327
+
328
+ output(:object) do
329
+ integer :id
330
+ string :label
331
+ resource hidden_account_resource
332
+ end
333
+
334
+ def exec
335
+ self.class.model.find(params['invoice_id'])
336
+ end
337
+ end
338
+
339
+ define_action(:Update, superclass: HaveAPI::Actions::Default::Update) do
340
+ authorize { allow }
341
+
342
+ input(:hash) do
343
+ resource hidden_account_resource
344
+ end
345
+
346
+ output(:hash) do
347
+ bool :assigned
348
+ end
349
+
350
+ def exec
351
+ { assigned: input.has_key?(:hidden_account) }
352
+ end
353
+ end
354
+ end
167
355
  end
168
356
 
169
357
  default_version 1
@@ -193,6 +381,21 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
193
381
  t.string :state
194
382
  t.integer :group_id
195
383
  end
384
+
385
+ create_table :hidden_accounts do |t|
386
+ t.string :label, null: false
387
+ t.string :private_reference, null: false
388
+ end
389
+
390
+ create_table :invoices do |t|
391
+ t.string :label, null: false
392
+ t.integer :hidden_account_id, null: false
393
+ end
394
+
395
+ create_table :string_accounts, id: false do |t|
396
+ t.string :uuid, primary_key: true
397
+ t.string :label, null: false
398
+ end
196
399
  end
197
400
  end
198
401
 
@@ -200,6 +403,9 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
200
403
  ARAdapterSpec::User.delete_all
201
404
  ARAdapterSpec::Group.delete_all
202
405
  ARAdapterSpec::Environment.delete_all
406
+ ARAdapterSpec::Invoice.delete_all
407
+ ARAdapterSpec::HiddenAccount.delete_all
408
+ ARAdapterSpec::StringAccount.delete_all
203
409
  end
204
410
 
205
411
  let(:dummy_action) do
@@ -275,7 +481,7 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
275
481
  end
276
482
 
277
483
  it 'parses nested includes and drops unknown associations' do
278
- parsed = dummy_action.ar_parse_includes(%w[group group__environment foo bar__baz])
484
+ parsed = dummy_action.ar_parse_includes(%w[group group__environment group__missing foo bar__baz])
279
485
 
280
486
  expect(parsed).to include(:group)
281
487
  expect(parsed.any? { |v| v.is_a?(Hash) && v.has_key?(:group) }).to be(true)
@@ -283,10 +489,17 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
283
489
  nested = parsed.detect { |v| v.is_a?(Hash) && v.has_key?(:group) }
284
490
  expect(nested[:group].flatten).to include(:environment)
285
491
 
492
+ expect(nested[:group].flatten).not_to include(:missing)
286
493
  expect(parsed).not_to include(:foo)
287
494
  expect(parsed).not_to include(:bar)
288
495
  end
289
496
 
497
+ it 'drops overly deep include paths before building ActiveRecord includes' do
498
+ deep_path = (['missing'] * 5_000).join('__')
499
+
500
+ expect(dummy_action.ar_parse_includes([deep_path])).to eq([])
501
+ end
502
+
290
503
  it 'returns unresolved associations without includes' do
291
504
  environment = ARAdapterSpec::Environment.create!(label: 'env', note: 'ENV_NOTE')
292
505
  group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GRP_NOTE', environment: environment)
@@ -320,6 +533,23 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
320
533
  expect(group_data[:environment]).not_to have_key(:note)
321
534
  end
322
535
 
536
+ it 'applies associated show output restrictions when included' do
537
+ group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GROUP_SECRET')
538
+ user = create_user(name: 'user', group: group)
539
+
540
+ get "/v1/filtered_groups/#{group.id}", {}, input: ''
541
+ expect(api_response).to be_ok
542
+ expect(api_response[:filtered_group]).not_to have_key(:note)
543
+
544
+ get "/v1/filtered_members/#{user.id}", { _meta: { includes: 'group' } }, input: ''
545
+
546
+ expect(api_response).to be_ok
547
+ group_data = api_response[:filtered_member][:group]
548
+ expect(group_data[:_meta][:resolved]).to be(true)
549
+ expect(group_data).to include(id: group.id, label: 'grp')
550
+ expect(group_data).not_to have_key(:note)
551
+ end
552
+
323
553
  it 'resolves nested associations when included' do
324
554
  environment = ARAdapterSpec::Environment.create!(label: 'env', note: 'ENV_NOTE')
325
555
  group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GRP_NOTE', environment: environment)
@@ -337,6 +567,129 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
337
567
  expect(env_data[:note]).to eq('ENV_NOTE')
338
568
  end
339
569
 
570
+ it 'drops invalid nested include paths from requests' do
571
+ group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GRP_NOTE')
572
+ user = create_user(name: 'user', group: group)
573
+
574
+ get "/v1/users/#{user.id}", { _meta: { includes: 'group__missing' } }, input: ''
575
+
576
+ expect(last_response.status).to eq(200)
577
+ expect(api_response).to be_ok
578
+ expect(api_response[:user][:group][:_meta][:resolved]).to be(false)
579
+ end
580
+
581
+ it 'does not expose unresolved associated resources denied by their show action' do
582
+ account = ARAdapterSpec::HiddenAccount.create!(
583
+ label: 'VIP billing account',
584
+ private_reference: 'SECRET-ACCOUNT-REF'
585
+ )
586
+ invoice = ARAdapterSpec::Invoice.create!(label: 'public invoice', hidden_account: account)
587
+
588
+ get "/v1/hidden_accounts/#{account.id}", {}, input: ''
589
+ expect(last_response.status).to eq(403)
590
+ expect(api_response).to be_failed
591
+
592
+ get "/v1/invoices/#{invoice.id}", {}, input: ''
593
+
594
+ expect(last_response.status).to eq(200)
595
+ expect(api_response).to be_ok
596
+
597
+ account_data = api_response[:invoice][:hidden_account]
598
+ expect(account_data).to eq(_meta: { resolved: false, authorized: false })
599
+ end
600
+
601
+ it 'does not resolve associated resources denied by their show action' do
602
+ account = ARAdapterSpec::HiddenAccount.create!(
603
+ label: 'VIP billing account',
604
+ private_reference: 'SECRET-ACCOUNT-REF'
605
+ )
606
+ invoice = ARAdapterSpec::Invoice.create!(label: 'public invoice', hidden_account: account)
607
+
608
+ get "/v1/invoices/#{invoice.id}", { _meta: { includes: 'hidden_account' } }, input: ''
609
+
610
+ expect(last_response.status).to eq(200)
611
+ expect(api_response).to be_ok
612
+
613
+ account_data = api_response[:invoice][:hidden_account]
614
+ expect(account_data).to eq(_meta: { resolved: false, authorized: false })
615
+ end
616
+
617
+ it 'does not expose unresolved associated resources denied during show prepare' do
618
+ group = ARAdapterSpec::Group.create!(
619
+ label: 'private group',
620
+ note: 'PRIVATE_GROUP_NOTE'
621
+ )
622
+ user = create_user(name: 'user', group: group)
623
+
624
+ get "/v1/groups/#{group.id}", {}, input: ''
625
+ expect(api_response).to be_failed
626
+
627
+ get "/v1/users/#{user.id}", {}, input: ''
628
+
629
+ expect(last_response.status).to eq(200)
630
+ expect(api_response).to be_ok
631
+
632
+ group_data = api_response[:user][:group]
633
+ expect(group_data).to eq(_meta: { resolved: false, authorized: false })
634
+ end
635
+
636
+ it 'does not resolve associated resources denied during show prepare' do
637
+ group = ARAdapterSpec::Group.create!(
638
+ label: 'private group',
639
+ note: 'PRIVATE_GROUP_NOTE'
640
+ )
641
+ user = create_user(name: 'user', group: group)
642
+
643
+ get "/v1/users/#{user.id}", { _meta: { includes: 'group' } }, input: ''
644
+
645
+ expect(last_response.status).to eq(200)
646
+ expect(api_response).to be_ok
647
+
648
+ group_data = api_response[:user][:group]
649
+ expect(group_data).to eq(_meta: { resolved: false, authorized: false })
650
+ end
651
+
652
+ it 'does not expose object path params when id output is filtered' do
653
+ user = create_user(name: 'user')
654
+
655
+ get "/v1/users/public/#{user.id}/show", {}, input: ''
656
+
657
+ expect(last_response.status).to eq(200)
658
+ expect(api_response).to be_ok
659
+ expect(api_response[:user]).to eq(name: 'user')
660
+ expect(api_response.response[:_meta]).not_to have_key(:path_params)
661
+ end
662
+
663
+ it 'does not expose object-list path params when id output is filtered' do
664
+ create_user(id: 1, name: 'user1')
665
+ create_user(id: 2, name: 'user2')
666
+
667
+ get '/v1/users/public/list', {}, input: ''
668
+
669
+ expect(last_response.status).to eq(200)
670
+ expect(api_response).to be_ok
671
+ expect(api_response[:users].map { |user| user[:name] }).to eq(%w[user1 user2])
672
+ expect(api_response[:users]).to all(satisfy { |user| !user[:_meta].has_key?(:path_params) })
673
+ end
674
+
675
+ it 'rejects resource input records denied by their show action' do
676
+ account = ARAdapterSpec::HiddenAccount.create!(
677
+ label: 'VIP billing account',
678
+ private_reference: 'SECRET-ACCOUNT-REF'
679
+ )
680
+ invoice = ARAdapterSpec::Invoice.create!(label: 'public invoice', hidden_account: account)
681
+
682
+ put "/v1/invoices/#{invoice.id}", {
683
+ invoice: {
684
+ hidden_account: account.id
685
+ }
686
+ }.to_json, 'CONTENT_TYPE' => 'application/json'
687
+
688
+ expect(last_response.status).to eq(400)
689
+ expect(api_response).not_to be_ok
690
+ expect(api_response.errors[:hidden_account]).to include('resource not found')
691
+ end
692
+
340
693
  it 'cleans resource input ids and maps invalid values to validation errors' do
341
694
  environment = ARAdapterSpec::Environment.create!(id: 1, label: 'env')
342
695
 
@@ -371,6 +724,48 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
371
724
  end.to raise_error(HaveAPI::ValidationError, /resource not found/)
372
725
  end
373
726
 
727
+ it 'rejects nil results from non-nullable custom resource fetchers' do
728
+ fetch = proc { |id| find_by(id:) }
729
+
730
+ expect do
731
+ described_class::Input.clean(ARAdapterSpec::Environment, 9999, { fetch: })
732
+ end.to raise_error(HaveAPI::ValidationError, /resource not found/)
733
+
734
+ cleaned = described_class::Input.clean(
735
+ ARAdapterSpec::Environment,
736
+ 9999,
737
+ { fetch:, nullable: true }
738
+ )
739
+ expect(cleaned).to be_nil
740
+ end
741
+
742
+ it 'rejects arrays and hashes for singular string primary-key resources' do
743
+ ARAdapterSpec::StringAccount.create!(uuid: 'acct-alpha', label: 'Alpha')
744
+ ARAdapterSpec::StringAccount.create!(uuid: 'acct-beta', label: 'Beta')
745
+
746
+ expect do
747
+ described_class::Input.clean(ARAdapterSpec::StringAccount, %w[acct-alpha acct-beta], {})
748
+ end.to raise_error(HaveAPI::ValidationError, /not a valid id/)
749
+
750
+ expect do
751
+ described_class::Input.clean(ARAdapterSpec::StringAccount, { id: 'acct-alpha' }, {})
752
+ end.to raise_error(HaveAPI::ValidationError, /not a valid id/)
753
+ end
754
+
755
+ it 'rejects non-string includes metadata entries' do
756
+ app
757
+ show_action = action_class(:User, :show)
758
+ includes_param = show_action.meta(:global).input[:includes]
759
+
760
+ expect do
761
+ includes_param.clean([{ bad: 'shape' }])
762
+ end.to raise_error(HaveAPI::ValidationError, /only strings/)
763
+ end
764
+
765
+ it 'keeps hash adapter resource cleaning compatible with adapter arguments' do
766
+ expect(HaveAPI::ModelAdapters::Hash::Input.clean({}, { id: 1 }, {})).to eq({ id: 1 })
767
+ end
768
+
374
769
  it 'applies ascending pagination with with_pagination' do
375
770
  5.times do |i|
376
771
  create_user(
@@ -388,6 +783,16 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
388
783
  expect(ids).to eq([3, 4])
389
784
  end
390
785
 
786
+ it 'rejects excessive pagination limits' do
787
+ get '/v1/users', { user: { limit: HaveAPI::Actions::Paginable::MAX_LIMIT + 1 } }, input: ''
788
+
789
+ expect(last_response.status).to eq(400)
790
+ expect(api_response).not_to be_ok
791
+ expect(api_response.errors[:limit].first).to include(
792
+ "range <0, #{HaveAPI::Actions::Paginable::MAX_LIMIT}>"
793
+ )
794
+ end
795
+
391
796
  it 'applies descending pagination with with_desc_pagination' do
392
797
  5.times do |i|
393
798
  create_user(
@@ -1,4 +1,6 @@
1
1
  require 'time'
2
+ require 'open3'
3
+ require 'rbconfig'
2
4
 
3
5
  describe 'Parameters::Typed' do
4
6
  def p_type(type)
@@ -133,6 +135,7 @@ describe 'Parameters::Typed' do
133
135
  expect { p.clean('bzz') }.to raise_error(HaveAPI::ValidationError)
134
136
  expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
135
137
  expect { p.clean(nil) }.to raise_error(HaveAPI::ValidationError)
138
+ expect { p.clean([]) }.to raise_error(HaveAPI::ValidationError)
136
139
 
137
140
  p = p_arg(type: Datetime, required: true)
138
141
  expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
@@ -182,6 +185,24 @@ describe 'Parameters::Typed' do
182
185
  expect(p.clean(nil)).to be_nil
183
186
  end
184
187
 
188
+ it 'rejects nil returned by custom cleaners unless nullable' do
189
+ p = p_arg(type: String, required: true, clean: proc {})
190
+ expect { p.clean('value') }.to raise_error(HaveAPI::ValidationError, /cannot be null/)
191
+
192
+ p = p_arg(type: String, nullable: true, clean: proc {})
193
+ expect(p.clean('value')).to be_nil
194
+ end
195
+
196
+ it 'rejects invalid string encoding during coercion' do
197
+ invalid = "\xff".b.force_encoding(Encoding::UTF_8)
198
+
199
+ expect { p_type(String).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
200
+ expect { p_type(Integer).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
201
+ expect { p_type(Float).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
202
+ expect { p_type(Boolean).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
203
+ expect { p_type(Datetime).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
204
+ end
205
+
185
206
  it 'can be protected' do
186
207
  p = p_arg(protected: true)
187
208
  expect(p.describe(nil)[:protected]).to be true
@@ -190,6 +211,27 @@ describe 'Parameters::Typed' do
190
211
  expect(p.describe(nil)[:protected]).to be false
191
212
  end
192
213
 
214
+ it 'loads Time#iso8601 for datetime output formatting' do
215
+ code = <<~RUBY
216
+ require 'haveapi/types'
217
+ require 'haveapi/parameters/typed'
218
+
219
+ param = HaveAPI::Parameters::Typed.new(:at, type: Datetime)
220
+ print param.format_output(Time.utc(1970, 1, 1))
221
+ RUBY
222
+
223
+ stdout, stderr, status = Open3.capture3(
224
+ RbConfig.ruby,
225
+ '-I',
226
+ File.expand_path('../../lib', __dir__),
227
+ '-e',
228
+ code
229
+ )
230
+
231
+ expect(status).to be_success, stderr
232
+ expect(stdout).to eq('1970-01-01T00:00:00Z')
233
+ end
234
+
193
235
  it 'is unprotected by default' do
194
236
  p = p_arg
195
237
  expect(p.describe(nil)[:protected]).to be false
data/spec/params_spec.rb CHANGED
@@ -53,6 +53,18 @@ describe HaveAPI::Params do
53
53
  expect(p.params.map(&:name)).to match_array(%i[res_param1 res_param2])
54
54
  end
55
55
 
56
+ it 'normalizes string include and exclude names from shared params' do
57
+ p = described_class.new(:input, ParamsSpec::MyResource::Index)
58
+ p.add_block proc { use :all, include: ['res_param1'] }
59
+ p.exec
60
+ expect(p.params.map(&:name)).to eq([:res_param1])
61
+
62
+ p = described_class.new(:input, ParamsSpec::MyResource::Index)
63
+ p.add_block proc { use :all, exclude: ['res_param2'] }
64
+ p.exec
65
+ expect(p.params.map(&:name)).to eq([:res_param1])
66
+ end
67
+
56
68
  it 'has param requires' do
57
69
  p = described_class.new(:input, ParamsSpec::MyResource::Index)
58
70
  p.add_block proc { requires :p_required }
@@ -176,6 +188,35 @@ describe HaveAPI::Params do
176
188
  end.to raise_error(HaveAPI::ValidationError)
177
189
  end
178
190
 
191
+ it 'rejects present optional namespaces with invalid shapes' do
192
+ p = described_class.new(:input, ParamsSpec::MyResource::Index)
193
+ p.add_block(proc do
194
+ string :param1
195
+ end)
196
+ p.exec
197
+
198
+ expect do
199
+ p.check_layout({
200
+ my_resource: 'not-a-hash'
201
+ })
202
+ end.to raise_error(HaveAPI::ValidationError)
203
+ end
204
+
205
+ it 'rejects non-hash list elements' do
206
+ p = described_class.new(:input, ParamsSpec::MyResource::Index)
207
+ p.layout = :hash_list
208
+ p.add_block(proc do
209
+ string :param1
210
+ end)
211
+ p.exec
212
+
213
+ expect do
214
+ p.check_layout({
215
+ my_resources: ['not-a-hash']
216
+ })
217
+ end.to raise_error(HaveAPI::ValidationError)
218
+ end
219
+
179
220
  it 'indexes parameters by name' do
180
221
  p = described_class.new(:input, ParamsSpec::MyResource::Index)
181
222
  p.add_block(proc do