haveapi 0.27.2 → 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 +150 -71
  19. data/lib/haveapi/model_adapters/hash.rb +1 -1
  20. data/lib/haveapi/parameters/resource.rb +50 -6
  21. data/lib/haveapi/parameters/typed.rb +40 -13
  22. data/lib/haveapi/params.rb +27 -8
  23. data/lib/haveapi/resource.rb +4 -1
  24. data/lib/haveapi/resources/action_state.rb +13 -5
  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 +408 -3
  55. data/spec/parameters/typed_spec.rb +75 -7
  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 +31 -3
  65. metadata +8 -4
  66. data/shell.nix +0 -20
@@ -30,6 +30,289 @@ describe HaveAPI::Action do
30
30
  end
31
31
  end
32
32
 
33
+ define_action(:OptionalShape) do
34
+ route 'optional_shape'
35
+ http_method :post
36
+ authorize { allow }
37
+
38
+ input do
39
+ string :label
40
+ end
41
+
42
+ output do
43
+ bool :ok
44
+ end
45
+
46
+ def exec
47
+ { ok: true }
48
+ end
49
+ end
50
+
51
+ define_action(:Batch) do
52
+ route 'batch'
53
+ http_method :post
54
+ authorize { allow }
55
+
56
+ input(:hash_list) do
57
+ string :label, required: true
58
+ end
59
+
60
+ meta(:global) do
61
+ input do
62
+ bool :confirmed, required: true
63
+ end
64
+ end
65
+
66
+ output(:hash) do
67
+ integer :count
68
+ end
69
+
70
+ def exec
71
+ { count: input.size }
72
+ end
73
+ end
74
+
75
+ define_action(:OutputOnlyObjectMeta) do
76
+ route 'output_only_object_meta'
77
+ http_method :post
78
+ authorize { allow }
79
+
80
+ input do
81
+ string :msg
82
+ end
83
+
84
+ meta(:object) do
85
+ output do
86
+ string :etag
87
+ end
88
+ end
89
+
90
+ output do
91
+ string :msg
92
+ end
93
+
94
+ def exec
95
+ { msg: input[:msg] }
96
+ end
97
+ end
98
+
99
+ define_action(:DigitPath) do
100
+ route 'ipv4/{ip4_id}'
101
+ http_method :get
102
+
103
+ authorize do |_user, path_params|
104
+ deny if path_params['ip4_id'] == '1'
105
+
106
+ allow
107
+ end
108
+
109
+ output do
110
+ string :value
111
+ end
112
+
113
+ def exec
114
+ { value: 'ok' }
115
+ end
116
+ end
117
+
118
+ define_action(:ColonPath) do
119
+ route 'accounts/:account_id/secret'
120
+ http_method :get
121
+
122
+ authorize do |_user, path_params|
123
+ deny unless path_params['account_id'] == '1'
124
+
125
+ allow
126
+ end
127
+
128
+ output do
129
+ string :value
130
+ end
131
+
132
+ def exec
133
+ { value: params['account_id'] }
134
+ end
135
+ end
136
+
137
+ define_action(:BodyShadow) do
138
+ route 'profiles/{profile_id}'
139
+ http_method :put
140
+
141
+ authorize do |_user, path_params|
142
+ deny unless path_params['profile_id'] == '2'
143
+
144
+ allow
145
+ end
146
+
147
+ input do
148
+ string :name
149
+ end
150
+
151
+ output do
152
+ string :route_profile_id
153
+ end
154
+
155
+ def exec
156
+ { route_profile_id: params['profile_id'] }
157
+ end
158
+ end
159
+
160
+ define_action(:FilteredDefault) do
161
+ route 'filtered_default'
162
+ http_method :post
163
+
164
+ authorize do
165
+ input blacklist: [:admin]
166
+ allow
167
+ end
168
+
169
+ input do
170
+ string :name
171
+ bool :admin, default: true, fill: true
172
+ end
173
+
174
+ output do
175
+ bool :saw_admin
176
+ bool :admin
177
+ end
178
+
179
+ def exec
180
+ { saw_admin: input.has_key?(:admin), admin: input[:admin] }
181
+ end
182
+ end
183
+
184
+ define_action(:MetadataInput) do
185
+ route 'metadata_input'
186
+ http_method :post
187
+
188
+ authorize do
189
+ input blacklist: [:confirmed]
190
+ allow
191
+ end
192
+
193
+ meta(:global) do
194
+ input do
195
+ bool :confirmed
196
+ end
197
+ end
198
+
199
+ output do
200
+ bool :saw_confirmed
201
+ end
202
+
203
+ def exec
204
+ { saw_confirmed: meta.has_key?(:confirmed) }
205
+ end
206
+ end
207
+
208
+ define_action(:MetadataOutput) do
209
+ route 'metadata_output'
210
+ http_method :post
211
+
212
+ authorize do
213
+ output blacklist: [:secret_status]
214
+ allow
215
+ end
216
+
217
+ meta(:global) do
218
+ output do
219
+ string :public_status
220
+ string :secret_status
221
+ end
222
+ end
223
+
224
+ output do
225
+ bool :ok
226
+ end
227
+
228
+ def exec
229
+ set_meta(public_status: 'queued', secret_status: 'internal-token')
230
+ { ok: true }
231
+ end
232
+ end
233
+
234
+ define_action(:UnnamespacedInput) do
235
+ route 'unnamespaced_input'
236
+ http_method :post
237
+
238
+ authorize do
239
+ input blacklist: [:secret]
240
+ allow
241
+ end
242
+
243
+ input(:hash, namespace: false) do
244
+ string :public
245
+ string :secret
246
+ end
247
+
248
+ output do
249
+ bool :params_saw_secret
250
+ bool :input_saw_secret
251
+ end
252
+
253
+ def exec
254
+ {
255
+ params_saw_secret: params.has_key?(:secret),
256
+ input_saw_secret: input.has_key?(:secret)
257
+ }
258
+ end
259
+ end
260
+
261
+ define_action(:TopLevelBody) do
262
+ route 'top_level_body'
263
+ http_method :post
264
+
265
+ authorize do
266
+ input blacklist: [:secret]
267
+ allow
268
+ end
269
+
270
+ input do
271
+ string :public
272
+ string :secret
273
+ end
274
+
275
+ output do
276
+ bool :params_saw_secret
277
+ bool :input_saw_secret
278
+ end
279
+
280
+ def exec
281
+ {
282
+ params_saw_secret: params.has_key?(:secret),
283
+ input_saw_secret: input.has_key?(:secret)
284
+ }
285
+ end
286
+ end
287
+
288
+ define_action(:Closed) do
289
+ route 'closed'
290
+ http_method :post
291
+ auth false
292
+
293
+ output do
294
+ bool :ok
295
+ end
296
+
297
+ def exec
298
+ { ok: true }
299
+ end
300
+ end
301
+
302
+ define_action(:Show) do
303
+ route '{test_id}'
304
+ http_method :get
305
+ authorize { allow }
306
+
307
+ output do
308
+ string :id
309
+ end
310
+
311
+ def exec
312
+ { id: params['test_id'] }
313
+ end
314
+ end
315
+
33
316
  define_action(:Order) do
34
317
  http_method :post
35
318
  authorize { allow }
@@ -164,6 +447,149 @@ describe HaveAPI::Action do
164
447
  expect(api_response.response).to have_key(:_meta)
165
448
  end
166
449
 
450
+ it 'rejects optional-only input namespaces with invalid shapes' do
451
+ call_api([:Test], :optional_shape, { test: 'not-a-hash' })
452
+
453
+ expect(last_response.status).to eq(400)
454
+ expect(api_response).not_to be_ok
455
+ expect(api_response.message).to eq('invalid input layout')
456
+ end
457
+
458
+ it 'rejects list inputs with invalid element shapes' do
459
+ call_api([:Test], :batch, { tests: ['not-a-hash'], _meta: { confirmed: true } })
460
+
461
+ expect(last_response.status).to eq(400)
462
+ expect(api_response).not_to be_ok
463
+ expect(api_response.message).to eq('invalid input layout')
464
+ end
465
+
466
+ it 'validates global metadata on list input actions' do
467
+ call_api([:Test], :batch, { tests: [{ label: 'one' }] })
468
+
469
+ expect(last_response.status).to eq(400)
470
+ expect(api_response).not_to be_ok
471
+ expect(api_response.errors[:confirmed]).to include('required parameter missing')
472
+
473
+ call_api([:Test], :batch, { tests: [{ label: 'one' }], _meta: { confirmed: 'maybe' } })
474
+
475
+ expect(last_response.status).to eq(400)
476
+ expect(api_response).not_to be_ok
477
+ expect(api_response.errors[:confirmed].first).to include('not a valid boolean')
478
+ end
479
+
480
+ it 'rejects malformed metadata namespaces' do
481
+ call_api([:Test], :echo, { test: { msg: 'hi' }, _meta: 'not-a-hash' })
482
+
483
+ expect(last_response.status).to eq(400)
484
+ expect(api_response).not_to be_ok
485
+ expect(api_response.message).to eq('invalid input layout')
486
+ end
487
+
488
+ it 'ignores object metadata definitions without input' do
489
+ call_api([:Test], :output_only_object_meta, {
490
+ test: {
491
+ msg: 'hi',
492
+ _meta: {
493
+ etag: 'client-value'
494
+ }
495
+ }
496
+ })
497
+
498
+ expect(last_response.status).to eq(200)
499
+ expect(api_response).to be_ok
500
+ expect(api_response[:test][:msg]).to eq('hi')
501
+ end
502
+
503
+ it 'uses path parameters containing digits for authorization' do
504
+ get '/v1/tests/ipv4/1', {}, input: ''
505
+
506
+ expect(last_response.status).to eq(403)
507
+ expect(api_response).not_to be_ok
508
+ end
509
+
510
+ it 'uses colon-style route parameters for authorization' do
511
+ get '/v1/tests/accounts/2/secret', {}, input: ''
512
+
513
+ expect(last_response.status).to eq(403)
514
+ expect(api_response).not_to be_ok
515
+ end
516
+
517
+ it 'does not let JSON body keys shadow route parameters for authorization' do
518
+ call_api(:put, '/v1/tests/profiles/1', {
519
+ profile_id: '2',
520
+ test: {
521
+ name: 'attacker'
522
+ }
523
+ })
524
+
525
+ expect(last_response.status).to eq(403)
526
+ expect(api_response).not_to be_ok
527
+ end
528
+
529
+ it 'does not reintroduce filtered input defaults' do
530
+ call_api([:Test], :filtered_default, { test: { name: 'acct' } })
531
+
532
+ expect(last_response.status).to eq(200)
533
+ expect(api_response).to be_ok
534
+ expect(api_response[:test][:saw_admin]).to be(false)
535
+ end
536
+
537
+ it 'applies input authorization filters to metadata input' do
538
+ call_api([:Test], :metadata_input, { _meta: { confirmed: true } })
539
+
540
+ expect(last_response.status).to eq(200)
541
+ expect(api_response).to be_ok
542
+ expect(api_response[:test][:saw_confirmed]).to be(false)
543
+ end
544
+
545
+ it 'applies output authorization filters to global metadata' do
546
+ call_api([:Test], :metadata_output, {})
547
+
548
+ expect(last_response.status).to eq(200)
549
+ expect(api_response).to be_ok
550
+ expect(api_response.response[:_meta]).to include(public_status: 'queued')
551
+ expect(api_response.response[:_meta]).not_to have_key(:secret_status)
552
+ end
553
+
554
+ it 'filters unnamespaced input in the safe params view' do
555
+ call_api([:Test], :unnamespaced_input, { public: 'ok', secret: 'hidden' })
556
+
557
+ expect(last_response.status).to eq(200)
558
+ expect(api_response).to be_ok
559
+ expect(api_response[:test][:params_saw_secret]).to be(false)
560
+ expect(api_response[:test][:input_saw_secret]).to be(false)
561
+ end
562
+
563
+ it 'does not expose top-level JSON keys outside the input namespace as safe params' do
564
+ call_api([:Test], :top_level_body, {
565
+ secret: 'top-level hidden',
566
+ test: {
567
+ public: 'ok',
568
+ secret: 'nested hidden'
569
+ }
570
+ })
571
+
572
+ expect(last_response.status).to eq(200)
573
+ expect(api_response).to be_ok
574
+ expect(api_response[:test][:params_saw_secret]).to be(false)
575
+ expect(api_response[:test][:input_saw_secret]).to be(false)
576
+ end
577
+
578
+ it 'denies OPTIONS for actions without an authorization block without raising' do
579
+ call_api(:options, '/v1/tests/closed?method=POST')
580
+
581
+ expect(last_response.status).to eq(403)
582
+ expect(api_response).not_to be_ok
583
+ end
584
+
585
+ it 'rejects invalid route argument encoding in action descriptions' do
586
+ call_api(:options, '/v1/tests/%FF?method=GET')
587
+
588
+ expect(last_response.status).to eq(400)
589
+ expect(api_response).not_to be_ok
590
+ expect(api_response.message).to eq('invalid path parameter encoding')
591
+ end
592
+
167
593
  it 'runs prepare, pre_exec, exec in order' do
168
594
  action_class(:order).calls.clear
169
595
 
@@ -2,6 +2,17 @@ require 'time'
2
2
 
3
3
  module ActionStateSpec
4
4
  FIXED_TIME = Time.utc(2020, 1, 1, 0, 0, 0)
5
+ User = Struct.new(:id)
6
+
7
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
8
+ protected
9
+
10
+ def find_user(_request, username, password)
11
+ return unless username == 'user' && password == 'pass'
12
+
13
+ User.new(1)
14
+ end
15
+ end
5
16
 
6
17
  class State
7
18
  attr_reader :id, :label, :created_at, :updated_at, :status, :progress, :poll_calls
@@ -209,6 +220,16 @@ describe HaveAPI::Resources::ActionState do
209
220
  expect(state.poll_calls).to eq(0)
210
221
  end
211
222
 
223
+ it 'rejects excessive poll timeout values' do
224
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
225
+
226
+ get_action '/v1/action_states/1/poll', action_state: { timeout: 31 }
227
+
228
+ expect(last_response.status).to eq(400)
229
+ expect(api_response).not_to be_ok
230
+ expect(api_response.errors[:timeout].first).to include('range <0, 30>')
231
+ end
232
+
212
233
  it 'poll returns immediately when update_in check mismatches' do
213
234
  state = ActionStateSpec::State.new(
214
235
  id: 1,
@@ -298,4 +319,35 @@ describe HaveAPI::Resources::ActionState do
298
319
  expect(api_response.message).to eq('not supported')
299
320
  end
300
321
  end
322
+
323
+ context 'with action_state backend and authentication' do
324
+ empty_api
325
+ use_version 1
326
+ default_version 1
327
+ action_state ActionStateSpec::Backend
328
+ auth_chain ActionStateSpec::BasicProvider
329
+
330
+ before do
331
+ ActionStateSpec::Backend.reset!
332
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
333
+ header 'Accept', 'application/json'
334
+ end
335
+
336
+ it 'requires authentication for action state resources' do
337
+ get_action '/v1/action_states'
338
+
339
+ expect(last_response.status).to eq(401)
340
+ expect(api_response).not_to be_ok
341
+ expect(ActionStateSpec::Backend.list_calls).to be_empty
342
+ end
343
+
344
+ it 'passes authenticated users to the action state backend' do
345
+ login('user', 'pass')
346
+ get_action '/v1/action_states'
347
+
348
+ expect(last_response.status).to eq(200)
349
+ expect(api_response).to be_ok
350
+ expect(ActionStateSpec::Backend.list_calls.last[:user].id).to eq(1)
351
+ end
352
+ end
301
353
  end
@@ -105,4 +105,33 @@ describe HaveAPI::Authentication::Basic::Provider do
105
105
  expect(api_response).to be_failed
106
106
  expect(seen_users.last).to be_nil
107
107
  end
108
+
109
+ it 'treats malformed Authorization headers as failed authentication' do
110
+ invalid = (+"Basic \xFF").force_encoding(Encoding::UTF_8)
111
+ header 'Authorization', invalid
112
+
113
+ call_api(:post, '/v1/secures/ping', {})
114
+
115
+ expect(last_response.status).to eq(401)
116
+ expect(api_response).to be_failed
117
+ expect(seen_users.last).to be_nil
118
+ end
119
+
120
+ it 'returns an authentication error envelope from _login' do
121
+ get '/_login'
122
+
123
+ expect(last_response.status).to eq(401)
124
+ expect(last_response.headers['www-authenticate']).to include('Basic realm=')
125
+ expect(api_response).to be_failed
126
+ expect(api_response.message).to include('authenticate')
127
+ end
128
+
129
+ it 'returns an authentication error envelope from _logout' do
130
+ get '/_logout'
131
+
132
+ expect(last_response.status).to eq(401)
133
+ expect(last_response.headers['www-authenticate']).to include('Basic realm=')
134
+ expect(api_response).to be_failed
135
+ expect(api_response.message).to include('authenticate')
136
+ end
108
137
  end