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
|
@@ -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
|
data/spec/authorization_spec.rb
CHANGED
|
@@ -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
|
|
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
|
+
'<script>window.__haveapi_xss = 1</script>'
|
|
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"><script>window.__haveapi_host_xss=1</script>'
|
|
36
|
+
)
|
|
37
|
+
expect(last_response.body).to include(
|
|
38
|
+
'http://evil.test"><script>window.__haveapi_host_xss=1</script>'
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
data/spec/documentation_spec.rb
CHANGED
|
@@ -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)
|