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
data/lib/haveapi/version.rb
CHANGED
|
@@ -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 %>/#<%=
|
|
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
|
-
|
|
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/')
|
data/spec/action/runtime_spec.rb
CHANGED
|
@@ -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
|
|