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
@@ -94,6 +94,8 @@ module HaveAPI
94
94
  v = v.to_i
95
95
  end
96
96
 
97
+ return false unless v.is_a?(::Numeric)
98
+
97
99
  ret = true
98
100
  ret = false if @min && v < @min
99
101
  ret = false if @max && v > @max
@@ -35,8 +35,8 @@ module HaveAPI
35
35
  def valid?(v)
36
36
  return false if v.nil?
37
37
  return !v.strip.empty? if !@empty && v.is_a?(::String)
38
+ return !v.empty? if !@empty && v.respond_to?(:empty?)
38
39
 
39
- # FIXME: other data types?
40
40
  true
41
41
  end
42
42
  end
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.27.3'.freeze
3
+ VERSION = '0.28.0'.freeze
4
4
  end
@@ -1,2 +1,2 @@
1
1
  <h4><%= client.label %></h4>
2
- <pre><code class="<%= client.code %>"><%= client.auth(host, base_url, api_version, method, desc) %></code></pre>
2
+ <pre><code class="<%= client.code %>"><%= escape_html(client.auth(host, base_url, api_version, method, desc)) %></code></pre>
@@ -1,11 +1,11 @@
1
1
  <h6><%= client.label %></h6>
2
2
  <% sample = client.new(host, base_url, api_version, r_name, resource, a_name, action) %>
3
3
  <% if sample.respond_to?(:example) %>
4
- <pre><code class="<%= client.code %>"><%= sample.example(example) %></code></pre>
4
+ <pre><code class="<%= client.code %>"><%= escape_html(sample.example(example)) %></code></pre>
5
5
 
6
6
  <% else %>
7
7
  <h6>Request</h6>
8
- <pre><code class="<%= client.code %>"><%= sample.request(example) %></code></pre>
8
+ <pre><code class="<%= client.code %>"><%= escape_html(sample.request(example)) %></code></pre>
9
9
  <h6>Response</h6>
10
- <pre><code class="<%= client.code %>"><%= sample.response(example) %></code></pre>
10
+ <pre><code class="<%= client.code %>"><%= escape_html(sample.response(example)) %></code></pre>
11
11
  <% end %>
@@ -1,2 +1,2 @@
1
1
  <h4><%= client.label %></h4>
2
- <pre><code class="<%= client.code %>"><%= client.init(host, base_url, api_version) %></code></pre>
2
+ <pre><code class="<%= client.code %>"><%= escape_html(client.init(host, base_url, api_version)) %></code></pre>
@@ -1,7 +1,7 @@
1
1
  <h1 id="api">API v<%= @v %></h1>
2
2
 
3
3
  <ol class="breadcrumb">
4
- <li><a href="<%= root %>"><%= host %></a></li>
4
+ <li><a href="<%= escape_html(root) %>"><%= escape_html(host) %></a></li>
5
5
  <li class="active">v<%= @v %></li>
6
6
  </ol>
7
7
 
@@ -21,7 +21,7 @@
21
21
  <li><a href="https://github.com/vpsfreecz/haveapi-client-php" target="_blank">PHP</a></li>
22
22
  <li>
23
23
  <a href="https://github.com/vpsfreecz/haveapi-webui" target="_blank">Generic web interface</a>
24
- (<a href="https://webui.haveapi.org/v<%= version %>/#<%= escape(base_url) %>" target="_blank">connect to this API</a>)
24
+ (<a href="https://webui.haveapi.org/v<%= version %>/#<%= urlescape(base_url) %>" target="_blank">connect to this API</a>)
25
25
  </li>
26
26
  <li><a href="https://github.com/vpsfreecz/haveapi-fs" target="_blank">FUSE-based file system</a></li>
27
27
  </ul>
@@ -1,9 +1,11 @@
1
1
  <h1>Authentication</h1>
2
2
  <p class="authentication">
3
3
  <% if current_user %>
4
- Logged as <%= current_user.login %> [<a class="logout" href="<%= logout_url %>">logout</a>]
4
+ Logged as <%= escape_html(current_user.login) %> [<a class="logout" href="<%= escape_html(logout_url) %>">logout</a>]
5
+ <% elsif @help[:authentication].any? %>
6
+ <a class="login btn btn-default" href="<%= escape_html(url("#{root}_login")) %>">Login</a>
5
7
  <% else %>
6
- <a class="login btn btn-default" href="<%= url("#{root}_login") %>">Login</a>
8
+ Authentication disabled.
7
9
  <% end %>
8
10
  </p>
9
11
  <p>
@@ -9,6 +9,10 @@ module AuthorizeSpec
9
9
  end
10
10
  end
11
11
 
12
+ class << self
13
+ attr_accessor :shared_hash_list
14
+ end
15
+
12
16
  class BasicProvider < HaveAPI::Authentication::Basic::Provider
13
17
  protected
14
18
 
@@ -120,6 +124,46 @@ describe AuthorizeSpec do
120
124
  items.select { |item| item[:owner_id] == restrictions[:owner_id] }
121
125
  end
122
126
  end
127
+
128
+ define_action(:OwnerList) do
129
+ route 'owners/{owner_id}/list'
130
+ http_method :get
131
+
132
+ authorize do |_user, path_params|
133
+ restrict owner_id: path_params['owner_id'].to_i
134
+ allow
135
+ end
136
+
137
+ output(:object_list) do
138
+ integer :id
139
+ integer :owner_id
140
+ string :name
141
+ end
142
+
143
+ define_method(:exec) do
144
+ restrictions = with_restricted
145
+ items.select { |item| item[:owner_id] == restrictions[:owner_id] }
146
+ end
147
+ end
148
+
149
+ define_action(:HashList) do
150
+ route 'hash_list'
151
+ http_method :get
152
+
153
+ authorize do |user|
154
+ output blacklist: [:secret] unless user.admin?
155
+ allow
156
+ end
157
+
158
+ output(:hash_list) do
159
+ string :public
160
+ string :secret
161
+ end
162
+
163
+ def exec
164
+ AuthorizeSpec.shared_hash_list
165
+ end
166
+ end
123
167
  end
124
168
  end
125
169
 
@@ -137,11 +181,32 @@ describe AuthorizeSpec do
137
181
  }
138
182
  end
139
183
 
184
+ let(:full_hash_list) do
185
+ [
186
+ {
187
+ public: 'visible',
188
+ secret: 'admin-only'
189
+ }
190
+ ]
191
+ end
192
+
140
193
  def call_get_action(resource, action, params = {})
141
194
  env 'rack.input', StringIO.new('')
142
195
  call_api(resource, action, params)
143
196
  end
144
197
 
198
+ def with_pre_authorize(listener)
199
+ hooks = HaveAPI::Hooks.hooks
200
+ action_hooks = hooks[HaveAPI::Action][:pre_authorize]
201
+ original = action_hooks[:listeners].dup
202
+
203
+ HaveAPI::Action.connect_hook(:pre_authorize, &listener)
204
+
205
+ yield
206
+ ensure
207
+ action_hooks[:listeners] = original
208
+ end
209
+
145
210
  it 'denies non-admins from admin-only action' do
146
211
  login('user', 'pass')
147
212
  call_get_action([:Item], :admin_only, {})
@@ -226,6 +291,25 @@ describe AuthorizeSpec do
226
291
  expect(api_response[:item]).to have_key(:secret)
227
292
  end
228
293
 
294
+ it 'does not mutate shared hash list output while filtering fields' do
295
+ described_class.shared_hash_list = full_hash_list.map(&:dup)
296
+
297
+ login('user', 'pass')
298
+ call_get_action([:Item], :hash_list, {})
299
+
300
+ expect(last_response.status).to eq(200)
301
+ expect(api_response).to be_ok
302
+ expect(api_response[:items]).to eq([{ public: 'visible' }])
303
+ expect(described_class.shared_hash_list).to eq(full_hash_list)
304
+
305
+ login('admin', 'pass')
306
+ call_get_action([:Item], :hash_list, {})
307
+
308
+ expect(last_response.status).to eq(200)
309
+ expect(api_response).to be_ok
310
+ expect(api_response[:items]).to eq(full_hash_list)
311
+ end
312
+
229
313
  it 'restricts list results to the current user' do
230
314
  login('user', 'pass')
231
315
  call_get_action([:Item], :list, {})
@@ -259,6 +343,21 @@ describe AuthorizeSpec do
259
343
  expect(owners).to eq([1])
260
344
  end
261
345
 
346
+ it 'fails closed when action restrictions conflict with global restrictions' do
347
+ with_pre_authorize(proc do |ret, _context|
348
+ ret[:blocks] << proc do |user|
349
+ restrict owner_id: user.id
350
+ end
351
+ ret
352
+ end) do
353
+ login('user', 'pass')
354
+ get '/v1/items/owners/2/list', {}, input: ''
355
+ end
356
+
357
+ expect(last_response.status).to eq(403)
358
+ expect(api_response).not_to be_ok
359
+ end
360
+
262
361
  it 'hides admin-only actions from non-admin documentation' do
263
362
  login('user', 'pass')
264
363
  call_api(:options, '/v1/')
@@ -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