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.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/action.rb +125 -36
- data/lib/haveapi/actions/paginable.rb +3 -1
- data/lib/haveapi/authentication/basic/provider.rb +2 -0
- data/lib/haveapi/authentication/chain.rb +11 -7
- data/lib/haveapi/authentication/oauth2/config.rb +25 -3
- data/lib/haveapi/authentication/oauth2/provider.rb +92 -11
- data/lib/haveapi/authentication/oauth2/revoke_endpoint.rb +44 -3
- data/lib/haveapi/authentication/token/provider.rb +53 -15
- data/lib/haveapi/authorization.rb +42 -18
- data/lib/haveapi/client_examples/php_client.rb +1 -1
- data/lib/haveapi/client_examples/ruby_client.rb +1 -1
- data/lib/haveapi/context.rb +10 -4
- data/lib/haveapi/example.rb +15 -16
- data/lib/haveapi/extensions/action_exceptions.rb +6 -6
- data/lib/haveapi/model_adapters/active_record.rb +140 -68
- data/lib/haveapi/model_adapters/hash.rb +1 -1
- data/lib/haveapi/parameters/resource.rb +35 -3
- data/lib/haveapi/parameters/typed.rb +26 -7
- data/lib/haveapi/params.rb +27 -8
- data/lib/haveapi/resource.rb +4 -1
- data/lib/haveapi/resources/action_state.rb +8 -1
- data/lib/haveapi/route.rb +2 -2
- data/lib/haveapi/server.rb +137 -45
- data/lib/haveapi/validator.rb +2 -2
- data/lib/haveapi/validator_chain.rb +1 -0
- data/lib/haveapi/validators/confirmation.rb +1 -0
- data/lib/haveapi/validators/format.rb +6 -2
- data/lib/haveapi/validators/length.rb +2 -0
- data/lib/haveapi/validators/numericality.rb +2 -0
- data/lib/haveapi/validators/presence.rb +1 -1
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page/client_auth.erb +1 -1
- data/lib/haveapi/views/version_page/client_example.erb +3 -3
- data/lib/haveapi/views/version_page/client_init.erb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/lib/haveapi/views/version_sidebar.erb +4 -2
- data/spec/action/authorize_spec.rb +99 -0
- data/spec/action/runtime_spec.rb +426 -0
- data/spec/action_state_spec.rb +52 -0
- data/spec/authentication/basic_spec.rb +29 -0
- data/spec/authentication/oauth2_spec.rb +329 -0
- data/spec/authentication/token_spec.rb +195 -0
- data/spec/authentication/token_version_routes_spec.rb +164 -0
- data/spec/authorization_spec.rb +66 -0
- data/spec/documentation/auth_filtering_spec.rb +195 -1
- data/spec/documentation/current_user_html_escaping_spec.rb +47 -0
- data/spec/documentation/examples_spec.rb +97 -0
- data/spec/documentation/host_html_escaping_spec.rb +41 -0
- data/spec/documentation_spec.rb +13 -0
- data/spec/extensions/action_exceptions_spec.rb +30 -0
- data/spec/model_adapters/active_record_spec.rb +406 -1
- data/spec/parameters/typed_spec.rb +42 -0
- data/spec/params_spec.rb +41 -0
- data/spec/server/integration_spec.rb +90 -0
- data/spec/validator_chain_spec.rb +39 -0
- data/spec/validators/confirmation_spec.rb +14 -0
- data/spec/validators/format_spec.rb +7 -0
- data/spec/validators/length_spec.rb +6 -0
- data/spec/validators/numericality_spec.rb +7 -0
- data/spec/validators/presence_spec.rb +2 -0
- data/test_support/client_test_api.rb +28 -0
- metadata +8 -4
- 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
|