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
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ module AuthSpecTokenVersionRoutes
6
+ User = Struct.new(:id, :login)
7
+
8
+ class << self
9
+ def reset!
10
+ tokens.clear
11
+ end
12
+
13
+ def tokens
14
+ @tokens ||= {}
15
+ end
16
+ end
17
+
18
+ class LegacyConfig < HaveAPI::Authentication::Token::Config
19
+ request do
20
+ handle do |req, res|
21
+ if req.input[:user] == 'legacy' && req.input[:password] == 'pass'
22
+ AuthSpecTokenVersionRoutes.tokens['legacy-token'] = User.new(1, 'legacy')
23
+ res.token = 'legacy-token'
24
+ res.valid_to = Time.now + 3600
25
+ res.complete = true
26
+ res.ok
27
+ else
28
+ res.error = 'invalid legacy credentials'
29
+ res
30
+ end
31
+ end
32
+ end
33
+
34
+ renew do
35
+ handle { |_req, res| res.ok }
36
+ end
37
+
38
+ revoke do
39
+ handle { |_req, res| res.ok }
40
+ end
41
+
42
+ def find_user_by_token(_request, token)
43
+ AuthSpecTokenVersionRoutes.tokens[token]
44
+ end
45
+ end
46
+
47
+ class StrictConfig < HaveAPI::Authentication::Token::Config
48
+ request do
49
+ input do
50
+ string :mfa_code, required: true
51
+ end
52
+
53
+ handle do |_req, res|
54
+ res.error = 'strict token issuer reached'
55
+ res
56
+ end
57
+ end
58
+
59
+ renew do
60
+ handle { |_req, res| res.ok }
61
+ end
62
+
63
+ revoke do
64
+ handle { |_req, res| res.ok }
65
+ end
66
+
67
+ def find_user_by_token(_request, token)
68
+ AuthSpecTokenVersionRoutes.tokens[token]
69
+ end
70
+ end
71
+
72
+ LegacyProvider = HaveAPI::Authentication::Token.with_config(LegacyConfig)
73
+ StrictProvider = HaveAPI::Authentication::Token.with_config(StrictConfig)
74
+
75
+ ApiModule = Module.new do
76
+ def self.define_resource(name, superclass: HaveAPI::Resource, &block)
77
+ cls = Class.new(superclass)
78
+ const_set(name, cls)
79
+ cls.class_exec(&block)
80
+ cls
81
+ end
82
+
83
+ define_resource(:Secure) do
84
+ version 2
85
+
86
+ define_action(:Ping) do
87
+ route 'ping'
88
+ http_method :post
89
+ auth true
90
+
91
+ input(:hash) {}
92
+ output(:hash) { string :login }
93
+ authorize { allow }
94
+
95
+ def exec
96
+ { login: current_user.login }
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ describe HaveAPI::Authentication::Token do
104
+ let(:api_instance) do
105
+ api = HaveAPI::Server.new(AuthSpecTokenVersionRoutes::ApiModule)
106
+ api.use_version([1, 2])
107
+ api.default_version = 1
108
+ api.auth_chain[1] << AuthSpecTokenVersionRoutes::LegacyProvider
109
+ api.auth_chain[2] << AuthSpecTokenVersionRoutes::StrictProvider
110
+ api.mount('/')
111
+ api
112
+ end
113
+
114
+ before do
115
+ AuthSpecTokenVersionRoutes.reset!
116
+ api_instance
117
+ end
118
+
119
+ def app
120
+ api_instance.app
121
+ end
122
+
123
+ it 'does not mount version-specific token issuers on the shared auth path' do
124
+ call_api(:post, '/_auth/token/tokens', {
125
+ token: {
126
+ user: 'legacy',
127
+ password: 'pass',
128
+ lifetime: 'permanent',
129
+ interval: 60
130
+ }
131
+ })
132
+
133
+ expect(last_response.status).to eq(404)
134
+ expect(AuthSpecTokenVersionRoutes.tokens).to be_empty
135
+ end
136
+
137
+ it 'routes token requests through the matching API version issuer' do
138
+ call_api(:post, '/v2/_auth/token/tokens', {
139
+ token: {
140
+ user: 'legacy',
141
+ password: 'pass',
142
+ lifetime: 'permanent',
143
+ interval: 60
144
+ }
145
+ })
146
+
147
+ expect(last_response.status).to eq(400)
148
+ expect(api_response).to be_failed
149
+ expect(AuthSpecTokenVersionRoutes.tokens).to be_empty
150
+
151
+ call_api(:post, '/v1/_auth/token/tokens', {
152
+ token: {
153
+ user: 'legacy',
154
+ password: 'pass',
155
+ lifetime: 'permanent',
156
+ interval: 60
157
+ }
158
+ })
159
+
160
+ expect(last_response.status).to eq(200)
161
+ expect(api_response).to be_ok
162
+ expect(api_response[:token][:token]).to eq('legacy-token')
163
+ end
164
+ end
@@ -78,6 +78,30 @@ describe HaveAPI::Authorization do
78
78
  ).keys).to contain_exactly(:param2)
79
79
  end
80
80
 
81
+ it 'normalizes string blacklist entries' do
82
+ auth = described_class.new do
83
+ input blacklist: ['param1']
84
+ output blacklist: ['param1']
85
+ allow
86
+ end
87
+
88
+ expect(auth.authorized?(nil, {})).to be true
89
+
90
+ action = Resource::Index
91
+ input = action.model_adapter(action.input.layout).input(
92
+ param1: '123',
93
+ param2: '456'
94
+ )
95
+ output = action.model_adapter(action.output.layout).output(
96
+ nil,
97
+ param1: '123',
98
+ param2: '456'
99
+ )
100
+
101
+ expect(auth.filter_input(action.input.params, input).keys).to contain_exactly(:param2)
102
+ expect(auth.filter_output(action.output.params, output).keys).to contain_exactly(:param2)
103
+ end
104
+
81
105
  it 'whitelists output' do
82
106
  auth = described_class.new do
83
107
  output whitelist: %i[param1]
@@ -115,4 +139,46 @@ describe HaveAPI::Authorization do
115
139
  })
116
140
  ).keys).to contain_exactly(:param2)
117
141
  end
142
+
143
+ it 'does not read output parameters excluded by filters' do
144
+ auth = described_class.new do
145
+ output whitelist: %i[param1]
146
+ allow
147
+ end
148
+ action = Resource::Index
149
+ adapter = Class.new do
150
+ def has_param?(_name)
151
+ true
152
+ end
153
+
154
+ def [](name)
155
+ raise 'denied field was read' if name == :param2
156
+
157
+ 'visible'
158
+ end
159
+ end.new
160
+
161
+ expect(auth.authorized?(nil, {})).to be true
162
+ expect(auth.filter_output(action.output.params, adapter)).to eq(param1: 'visible')
163
+ end
164
+
165
+ it 'normalizes string restriction keys' do
166
+ auth = described_class.new do
167
+ restrict(**{ 'filter' => true })
168
+ allow
169
+ end
170
+
171
+ expect(auth.authorized?(nil, {})).to be true
172
+ expect(auth.restrictions).to eq(filter: true)
173
+ end
174
+
175
+ it 'denies conflicting duplicate restrictions' do
176
+ auth = described_class.new do
177
+ restrict filter: 1
178
+ restrict filter: 2
179
+ allow
180
+ end
181
+
182
+ expect(auth.authorized?(nil, {})).to be false
183
+ end
118
184
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable RSpec/InstanceVariable, RSpec/MultipleDescribes
4
+
3
5
  require 'spec_helper'
4
6
 
5
7
  module DocAuthFilteringSpec
@@ -9,13 +11,120 @@ module DocAuthFilteringSpec
9
11
  protected
10
12
 
11
13
  def find_user(_request, username, password)
12
- return User.new(1, username) if username == 'user' && password == 'pass'
14
+ return User.new(1, username) if %w[user admin].include?(username) && password == 'pass'
13
15
 
14
16
  nil
15
17
  end
16
18
  end
17
19
  end
18
20
 
21
+ module DocAuthProviderResourceSpec
22
+ class Provider < HaveAPI::Authentication::Base
23
+ auth_method :secret_auth
24
+
25
+ def resource_module
26
+ Resources
27
+ end
28
+
29
+ def authenticate(_request)
30
+ :user
31
+ end
32
+ end
33
+
34
+ module Resources
35
+ class HiddenToken < HaveAPI::Resource
36
+ desc 'Internal authentication resource'
37
+ auth false
38
+ version :all
39
+
40
+ class Show < HaveAPI::Action
41
+ route '{hidden_token_id}'
42
+
43
+ output(:hash) do
44
+ string :id
45
+ end
46
+
47
+ authorize { deny }
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ module CrossVersionDocAuthSpec
54
+ User = Struct.new(:login)
55
+
56
+ class V1Provider < HaveAPI::Authentication::Basic::Provider
57
+ protected
58
+
59
+ def find_user(_request, username, password)
60
+ User.new(username) if username == 'v1-user' && password == 'pass'
61
+ end
62
+ end
63
+
64
+ class V2Provider < HaveAPI::Authentication::Basic::Provider
65
+ protected
66
+
67
+ def find_user(_request, _username, _password)
68
+ nil
69
+ end
70
+ end
71
+
72
+ ApiModule = Module.new do
73
+ def self.define_resource(name, superclass: HaveAPI::Resource, &block)
74
+ cls = Class.new(superclass)
75
+ const_set(name, cls)
76
+ cls.class_exec(&block)
77
+ cls
78
+ end
79
+
80
+ define_resource(:Public) do
81
+ version 1
82
+ route 'publics'
83
+
84
+ define_action(:Ping) do
85
+ route 'ping'
86
+ http_method :get
87
+ auth true
88
+
89
+ output(:hash) do
90
+ string :msg
91
+ end
92
+
93
+ authorize { |user| user ? allow : deny }
94
+
95
+ def exec
96
+ { msg: 'v1' }
97
+ end
98
+ end
99
+ end
100
+
101
+ define_resource(:Secret) do
102
+ version 2
103
+ route 'secrets'
104
+
105
+ define_action(:Reveal) do
106
+ route 'reveal'
107
+ http_method :get
108
+ auth true
109
+
110
+ input(:hash) do
111
+ string :internal_ticket, required: true
112
+ end
113
+
114
+ output(:hash) do
115
+ string :secret_material
116
+ end
117
+
118
+ authorize { |user| user ? allow : deny }
119
+
120
+ def exec
121
+ { secret_material: 'v2' }
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+
19
128
  describe DocAuthFilteringSpec do
20
129
  context 'when viewing documentation' do
21
130
  api do
@@ -31,6 +140,13 @@ describe DocAuthFilteringSpec do
31
140
  output(:hash) { string :msg }
32
141
  authorize { allow }
33
142
 
143
+ # rubocop:disable RSpec/NoExpectationExample
144
+ example 'admin-only public result' do
145
+ authorize { |user| user&.login == 'admin' }
146
+ response({ msg: 'ADMIN_ONLY_RESULT' })
147
+ end
148
+ # rubocop:enable RSpec/NoExpectationExample
149
+
34
150
  def exec
35
151
  { msg: 'public' }
36
152
  end
@@ -48,6 +164,15 @@ describe DocAuthFilteringSpec do
48
164
  { msg: 'private' }
49
165
  end
50
166
  end
167
+
168
+ define_resource(:HiddenChild) do
169
+ desc 'Hidden nested resource'
170
+ auth false
171
+
172
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
173
+ authorize { deny }
174
+ end
175
+ end
51
176
  end
52
177
  end
53
178
 
@@ -84,6 +209,15 @@ describe DocAuthFilteringSpec do
84
209
  expect(actions).to have_key(:private)
85
210
  end
86
211
 
212
+ it 'prunes nested resources with no authorized actions or children' do
213
+ login('user', 'pass')
214
+ call_api(:options, '/v1/')
215
+
216
+ expect(last_response.status).to eq(200)
217
+ expect(api_response).to be_ok
218
+ expect(api_response[:resources][:secure][:resources]).not_to have_key(:hidden_child)
219
+ end
220
+
87
221
  it 'restricts action documentation for private actions' do
88
222
  header 'Authorization', nil
89
223
  call_api(:options, '/v1/secures/private?method=GET')
@@ -107,5 +241,65 @@ describe DocAuthFilteringSpec do
107
241
  expect(api_response).to be_ok
108
242
  expect(api_response[:method]).to eq('GET')
109
243
  end
244
+
245
+ it 'hides examples denied to anonymous users from version docs' do
246
+ header 'Authorization', nil
247
+ call_api(:options, '/v1/')
248
+
249
+ expect(last_response.status).to eq(200)
250
+ expect(api_response).to be_ok
251
+
252
+ examples = api_response[:resources][:secure][:actions][:public][:examples]
253
+ expect(examples).to be_empty
254
+ end
255
+
256
+ it 'shows examples allowed to authenticated users in version docs' do
257
+ login('admin', 'pass')
258
+ call_api(:options, '/v1/')
259
+
260
+ expect(last_response.status).to eq(200)
261
+ expect(api_response).to be_ok
262
+
263
+ examples = api_response[:resources][:secure][:actions][:public][:examples]
264
+ expect(examples.size).to eq(1)
265
+ expect(examples.first[:response][:msg]).to eq('ADMIN_ONLY_RESULT')
266
+ end
110
267
  end
111
268
  end
269
+
270
+ describe DocAuthProviderResourceSpec do
271
+ empty_api
272
+ use_version 1
273
+ auth_chain DocAuthProviderResourceSpec::Provider
274
+
275
+ it 'prunes provider resources with no authorized actions or children' do
276
+ call_api(:options, '/v1/')
277
+
278
+ expect(last_response.status).to eq(200)
279
+ expect(api_response).to be_ok
280
+ expect(api_response[:authentication][:secret_auth][:resources]).to be_empty
281
+ end
282
+ end
283
+
284
+ describe CrossVersionDocAuthSpec do
285
+ before do
286
+ @api = HaveAPI::Server.new(CrossVersionDocAuthSpec::ApiModule)
287
+ @api.use_version([1, 2])
288
+ @api.default_version = 1
289
+ @api.auth_chain[1] << CrossVersionDocAuthSpec::V1Provider
290
+ @api.auth_chain[2] << CrossVersionDocAuthSpec::V2Provider
291
+ @api.mount('/')
292
+ end
293
+
294
+ it 'does not describe versions that reject the supplied credentials' do
295
+ login('v1-user', 'pass')
296
+ call_api(:options, '/')
297
+
298
+ expect(last_response.status).to eq(200)
299
+ expect(api_response).to be_ok
300
+ expect(api_response[:versions]).to have_key(:'1')
301
+ expect(api_response[:versions]).not_to have_key(:'2')
302
+ end
303
+ end
304
+
305
+ # rubocop:enable RSpec/InstanceVariable, RSpec/MultipleDescribes
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ module CurrentUserHtmlEscapingSpec
6
+ User = Struct.new(:id, :login)
7
+
8
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
9
+ protected
10
+
11
+ def find_user(_request, username, password)
12
+ return nil unless password == 'pass'
13
+
14
+ User.new(1, username)
15
+ end
16
+ end
17
+ end
18
+
19
+ describe CurrentUserHtmlEscapingSpec do
20
+ api do
21
+ define_resource(:Ping) do
22
+ version 1
23
+ auth false
24
+
25
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
26
+ authorize { allow }
27
+ end
28
+ end
29
+ end
30
+
31
+ default_version 1
32
+ auth_chain CurrentUserHtmlEscapingSpec::BasicProvider
33
+
34
+ it 'escapes the authenticated login in HTML documentation' do
35
+ payload = '<script>window.__haveapi_xss = 1</script>'
36
+
37
+ login(payload, 'pass')
38
+ get '/v1/'
39
+
40
+ expect(last_response.status).to eq(200)
41
+ expect(last_response.headers['Content-Type']).to include('text/html')
42
+ expect(last_response.body).not_to include(payload)
43
+ expect(last_response.body).to include(
44
+ '&lt;script&gt;window.__haveapi_xss = 1&lt;/script&gt;'
45
+ )
46
+ end
47
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe HaveAPI::Example do
6
+ api do
7
+ define_resource(:Widget) do
8
+ version 1
9
+ auth false
10
+
11
+ define_action(:BulkImport) do
12
+ route 'bulk_import'
13
+ http_method :post
14
+ authorize { allow }
15
+
16
+ input(:hash_list) do
17
+ string :name
18
+ end
19
+
20
+ output(:hash) do
21
+ integer :count
22
+ end
23
+
24
+ # rubocop:disable RSpec/NoExpectationExample
25
+ example 'bulk import' do
26
+ request([{ name: 'alpha' }])
27
+ response({ count: 1 })
28
+ end
29
+ # rubocop:enable RSpec/NoExpectationExample
30
+
31
+ def exec
32
+ { count: input.size }
33
+ end
34
+ end
35
+
36
+ define_action(:ProseOnly) do
37
+ route 'prose_only'
38
+ http_method :get
39
+ authorize { allow }
40
+
41
+ input(:hash) do
42
+ string :filter
43
+ end
44
+
45
+ output(:hash) do
46
+ string :name
47
+ end
48
+
49
+ # rubocop:disable RSpec/NoExpectationExample
50
+ example 'prose only' do
51
+ comment 'No request or response body is needed.'
52
+ end
53
+ # rubocop:enable RSpec/NoExpectationExample
54
+
55
+ def exec
56
+ { name: 'alpha' }
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ default_version 1
63
+
64
+ it 'describes list-shaped request examples' do
65
+ call_api(:options, '/v1/')
66
+
67
+ expect(last_response.status).to eq(200)
68
+ expect(api_response).to be_ok
69
+
70
+ examples = api_response[:resources][:widget][:actions][:bulk_import][:examples]
71
+ expect(examples.first[:request]).to eq([{ name: 'alpha' }])
72
+
73
+ call_api(:options, '/v1/widgets/bulk_import?method=POST')
74
+
75
+ expect(last_response.status).to eq(200)
76
+ expect(api_response).to be_ok
77
+ expect(api_response[:examples].first[:request]).to eq([{ name: 'alpha' }])
78
+ end
79
+
80
+ it 'describes prose-only examples without filtering missing bodies' do
81
+ call_api(:options, '/v1/')
82
+
83
+ expect(last_response.status).to eq(200)
84
+ expect(api_response).to be_ok
85
+
86
+ examples = api_response[:resources][:widget][:actions][:prose_only][:examples]
87
+ expect(examples.first[:request]).to be_nil
88
+ expect(examples.first[:response]).to be_nil
89
+
90
+ call_api(:options, '/v1/widgets/prose_only?method=GET')
91
+
92
+ expect(last_response.status).to eq(200)
93
+ expect(api_response).to be_ok
94
+ expect(api_response[:examples].first[:request]).to be_nil
95
+ expect(api_response[:examples].first[:response]).to be_nil
96
+ end
97
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ module HostHtmlEscapingSpec
6
+ end
7
+
8
+ describe HostHtmlEscapingSpec do
9
+ api do
10
+ define_resource(:Widget) do
11
+ version 1
12
+ auth false
13
+
14
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
15
+ authorize { allow }
16
+ end
17
+ end
18
+ end
19
+
20
+ default_version 1
21
+
22
+ it 'escapes request Host values in HTML docs and generated code examples' do
23
+ payload = 'evil.test"><script>window.__haveapi_host_xss=1</script>'
24
+
25
+ header 'Host', payload
26
+ get '/v1/'
27
+
28
+ expect(last_response.status).to eq(200)
29
+ expect(last_response.headers['Content-Type']).to include('text/html')
30
+ expect(last_response.body).not_to include('class="login')
31
+ expect(last_response.body).not_to include(
32
+ '<script>window.__haveapi_host_xss=1</script>'
33
+ )
34
+ expect(last_response.body).to include(
35
+ 'evil.test&quot;&gt;&lt;script&gt;window.__haveapi_host_xss=1&lt;/script&gt;'
36
+ )
37
+ expect(last_response.body).to include(
38
+ 'http://evil.test&quot;&gt;&lt;script&gt;window.__haveapi_host_xss=1&lt;/script&gt;'
39
+ )
40
+ end
41
+ end
@@ -27,6 +27,10 @@ describe 'Documentation' do
27
27
  define_action(:Update, superclass: HaveAPI::Actions::Default::Update) do
28
28
  desc 'Update user'
29
29
  authorize { allow }
30
+
31
+ input(:hash) do
32
+ string :otp, validate: 'verified by MFA backend'
33
+ end
30
34
  end
31
35
  end
32
36
 
@@ -173,10 +177,19 @@ describe 'Documentation' do
173
177
  expect(last_response.body).to include('HaveAPI documentation')
174
178
  end
175
179
 
180
+ it 'has online index doc page without a sidebar template' do
181
+ get '/doc/index'
182
+
183
+ expect(last_response.status).to eq(200)
184
+ expect(last_response.headers['Content-Type']).to include('text/html')
185
+ expect(last_response.body).to include('HaveAPI documentation')
186
+ end
187
+
176
188
  it 'has online doc for every version' do
177
189
  get '/v1/'
178
190
  expect(last_response.status).to eq(200)
179
191
  expect(last_response.headers['Content-Type']).to include('text/html')
192
+ expect(last_response.body).to include('verified by MFA backend')
180
193
 
181
194
  get '/v2/'
182
195
  expect(last_response.status).to eq(200)