haveapi-client 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/lib/haveapi/cli/cli.rb +8 -4
- data/lib/haveapi/client/action.rb +12 -4
- data/lib/haveapi/client/authentication/token.rb +10 -1
- data/lib/haveapi/client/client.rb +23 -7
- data/lib/haveapi/client/resource.rb +33 -13
- data/lib/haveapi/client/version.rb +1 -1
- data/spec/action_security_spec.rb +159 -0
- data/spec/authentication_token_security_spec.rb +90 -0
- data/spec/cli_security_spec.rb +67 -0
- data/spec/description_method_security_spec.rb +133 -0
- metadata +5 -2
- data/shell.nix +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e8b97599e4a2afdab12412cb851dced954f8efc0961a69055947cefd69edc28
|
|
4
|
+
data.tar.gz: ee5f1df9fa2e1687cbccf240c6de1a38cf66a6765621a7c56dbf1cd72213ded7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7fdd0d0e2b1cab27295094fd8ec99300e62226685fb223fcec684f1126ab611be2db1b9e0367b807c888748c665761af5baedca3b03f98750130a02e616ae2a9
|
|
7
|
+
data.tar.gz: c80f685e74a80e3e4ba440d380ef4d60fd566606d35e8b8ed19478567045b1f3539586d5786bea0a2a47f179fbf6a05f62c91b73d65e744307c3d3e66547ecb8
|
data/lib/haveapi/cli/cli.rb
CHANGED
|
@@ -72,7 +72,7 @@ module HaveAPI::CLI
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
if (sep = ARGV.index('--'))
|
|
75
|
-
cmd_opt.parse!(ARGV[sep + 1..])
|
|
75
|
+
cmd_opt.parse!(ARGV[(sep + 1)..])
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
c.exec(args[2..] || [])
|
|
@@ -80,7 +80,7 @@ module HaveAPI::CLI
|
|
|
80
80
|
exit
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
if args.
|
|
83
|
+
if args.one?
|
|
84
84
|
describe_resource(resources)
|
|
85
85
|
exit
|
|
86
86
|
end
|
|
@@ -374,7 +374,7 @@ module HaveAPI::CLI
|
|
|
374
374
|
|
|
375
375
|
return {} unless sep
|
|
376
376
|
|
|
377
|
-
@action_opt.parse!(ARGV[sep + 1..])
|
|
377
|
+
@action_opt.parse!(ARGV[(sep + 1)..])
|
|
378
378
|
|
|
379
379
|
options
|
|
380
380
|
end
|
|
@@ -646,7 +646,11 @@ module HaveAPI::CLI
|
|
|
646
646
|
end
|
|
647
647
|
|
|
648
648
|
def write_config
|
|
649
|
-
File.
|
|
649
|
+
File.open(config_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
650
|
+
f.write(YAML.dump(@config))
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
File.chmod(0o600, config_path)
|
|
650
654
|
end
|
|
651
655
|
|
|
652
656
|
def read_config
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
1
3
|
module HaveAPI::Client
|
|
2
4
|
class Action
|
|
3
5
|
attr_reader :client, :api, :name
|
|
@@ -29,9 +31,9 @@ module HaveAPI::Client
|
|
|
29
31
|
params_arg = params.to_api
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
@api.call(self, params_arg)
|
|
35
|
+
ensure
|
|
33
36
|
reset
|
|
34
|
-
ret
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def auth?
|
|
@@ -237,9 +239,15 @@ module HaveAPI::Client
|
|
|
237
239
|
@prepared_help ||= @spec[:help].dup
|
|
238
240
|
|
|
239
241
|
args.each do |arg|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
+
encoded = encode_path_arg(arg)
|
|
243
|
+
|
|
244
|
+
@prepared_path.sub!(/\{[a-zA-Z0-9\-_]+\}/, encoded)
|
|
245
|
+
@prepared_help.sub!(/\{[a-zA-Z0-9\-_]+\}/, encoded)
|
|
242
246
|
end
|
|
243
247
|
end
|
|
248
|
+
|
|
249
|
+
def encode_path_arg(arg)
|
|
250
|
+
CGI.escape(arg.to_s).gsub('+', '%20')
|
|
251
|
+
end
|
|
244
252
|
end
|
|
245
253
|
end
|
|
@@ -3,6 +3,8 @@ require 'haveapi/client/authentication/base'
|
|
|
3
3
|
module HaveAPI::Client::Authentication
|
|
4
4
|
class Token < Base
|
|
5
5
|
register :token
|
|
6
|
+
HTTP_HEADER_NAME = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/
|
|
7
|
+
|
|
6
8
|
attr_reader :token, :valid_to
|
|
7
9
|
|
|
8
10
|
def setup
|
|
@@ -33,7 +35,7 @@ module HaveAPI::Client::Authentication
|
|
|
33
35
|
return {} unless @configured
|
|
34
36
|
|
|
35
37
|
check_validity
|
|
36
|
-
@via == :header ? {
|
|
38
|
+
@via == :header ? { http_header => @token } : {}
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def save
|
|
@@ -131,5 +133,12 @@ module HaveAPI::Client::Authentication
|
|
|
131
133
|
def auth_action_input(name)
|
|
132
134
|
@desc[:resources][:token][:actions][name][:input][:parameters].except(:token)
|
|
133
135
|
end
|
|
136
|
+
|
|
137
|
+
def http_header
|
|
138
|
+
header = @desc[:http_header]
|
|
139
|
+
return header if header.is_a?(String) && header.match?(HTTP_HEADER_NAME)
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, "invalid token authentication HTTP header name: #{header.inspect}"
|
|
142
|
+
end
|
|
134
143
|
end
|
|
135
144
|
end
|
|
@@ -100,7 +100,7 @@ class HaveAPI::Client::Client
|
|
|
100
100
|
|
|
101
101
|
setup_api
|
|
102
102
|
|
|
103
|
-
if @
|
|
103
|
+
if @resource_methods.include?(symbol)
|
|
104
104
|
method(symbol).call(*args)
|
|
105
105
|
|
|
106
106
|
else
|
|
@@ -112,7 +112,7 @@ class HaveAPI::Client::Client
|
|
|
112
112
|
return super if @setup
|
|
113
113
|
|
|
114
114
|
setup_api
|
|
115
|
-
@
|
|
115
|
+
@resource_methods.include?(symbol)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
private
|
|
@@ -120,17 +120,24 @@ class HaveAPI::Client::Client
|
|
|
120
120
|
# Get the description from the API and setup resource methods.
|
|
121
121
|
def setup_api
|
|
122
122
|
@description = @api.describe_api(@version)
|
|
123
|
+
old_resource_methods = @resource_methods || {}
|
|
123
124
|
@resources = {}
|
|
125
|
+
@resource_methods = {}
|
|
124
126
|
|
|
125
127
|
@description[:resources].each do |name, desc|
|
|
126
128
|
r = HaveAPI::Client::Resource.new(self, @api, name)
|
|
127
129
|
r.setup(desc)
|
|
130
|
+
method_name = name.to_sym
|
|
128
131
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
if old_resource_methods.include?(method_name) || define_resource_method?(name)
|
|
133
|
+
define_singleton_method(name) do |*args|
|
|
134
|
+
tmp = r.dup
|
|
135
|
+
tmp.prepared_args = args
|
|
136
|
+
tmp.setup_from_clone(r)
|
|
137
|
+
tmp
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
@resource_methods[method_name] = true
|
|
134
141
|
end
|
|
135
142
|
|
|
136
143
|
@resources[name] = r
|
|
@@ -138,4 +145,13 @@ class HaveAPI::Client::Client
|
|
|
138
145
|
|
|
139
146
|
@setup = true
|
|
140
147
|
end
|
|
148
|
+
|
|
149
|
+
def define_resource_method?(name)
|
|
150
|
+
method_name = name.to_sym
|
|
151
|
+
|
|
152
|
+
!singleton_class.public_method_defined?(method_name) &&
|
|
153
|
+
!singleton_class.protected_method_defined?(method_name) &&
|
|
154
|
+
!singleton_class.private_method_defined?(method_name, false) &&
|
|
155
|
+
!self.class.private_method_defined?(method_name, false)
|
|
156
|
+
end
|
|
141
157
|
end
|
|
@@ -78,24 +78,26 @@ module HaveAPI::Client
|
|
|
78
78
|
next unless define_method?(action, name)
|
|
79
79
|
|
|
80
80
|
define_singleton_method(name) do |*args, **kwargs, &block|
|
|
81
|
+
call_action = action.dup
|
|
82
|
+
call_action.reset
|
|
81
83
|
client_opts = @client.opts(:block, :block_interval, :block_timeout)
|
|
82
84
|
all_args = @prepared_args + args
|
|
83
85
|
|
|
84
|
-
if
|
|
86
|
+
if call_action.unresolved_args?
|
|
85
87
|
all_args.delete_if do |arg|
|
|
86
|
-
break unless
|
|
88
|
+
break unless call_action.unresolved_args?
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
call_action.provide_args(arg)
|
|
89
91
|
true
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
if
|
|
94
|
+
if call_action.unresolved_args?
|
|
93
95
|
raise ArgumentError, 'one or more object ids missing'
|
|
94
96
|
end
|
|
95
97
|
end
|
|
96
98
|
|
|
97
99
|
if all_args.length > 1 || (kwargs.any? && all_args.any?)
|
|
98
|
-
raise ArgumentError, "too many arguments for action #{@name}##{
|
|
100
|
+
raise ArgumentError, "too many arguments for action #{@name}##{call_action.name}"
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
arg = all_args.shift
|
|
@@ -111,7 +113,7 @@ module HaveAPI::Client
|
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
if user_params.nil?
|
|
114
|
-
input_params = default_action_input_params(
|
|
116
|
+
input_params = default_action_input_params(call_action)
|
|
115
117
|
|
|
116
118
|
else
|
|
117
119
|
if user_params.has_key?(:meta)
|
|
@@ -122,25 +124,25 @@ module HaveAPI::Client
|
|
|
122
124
|
end
|
|
123
125
|
end
|
|
124
126
|
|
|
125
|
-
input_params = default_action_input_params(
|
|
127
|
+
input_params = default_action_input_params(call_action).update(user_params)
|
|
126
128
|
end
|
|
127
129
|
|
|
128
|
-
ret = Response.new(
|
|
130
|
+
ret = Response.new(call_action, call_action.execute(input_params))
|
|
129
131
|
|
|
130
132
|
raise ActionFailed, ret unless ret.ok?
|
|
131
133
|
|
|
132
|
-
return_value = case
|
|
134
|
+
return_value = case call_action.output && call_action.output_layout
|
|
133
135
|
when :object
|
|
134
|
-
ResourceInstance.new(@client, @api, self, action:
|
|
136
|
+
ResourceInstance.new(@client, @api, self, action: call_action, response: ret)
|
|
135
137
|
|
|
136
138
|
when :object_list
|
|
137
|
-
ResourceInstanceList.new(@client, @api, self,
|
|
139
|
+
ResourceInstanceList.new(@client, @api, self, call_action, ret)
|
|
138
140
|
|
|
139
141
|
else # :hash, :hash_list
|
|
140
142
|
ret
|
|
141
143
|
end
|
|
142
144
|
|
|
143
|
-
if
|
|
145
|
+
if call_action.blocking? && client_opts[:block]
|
|
144
146
|
wait_opts = {}
|
|
145
147
|
|
|
146
148
|
{
|
|
@@ -156,6 +158,8 @@ module HaveAPI::Client
|
|
|
156
158
|
end
|
|
157
159
|
|
|
158
160
|
return_value
|
|
161
|
+
ensure
|
|
162
|
+
call_action.reset if call_action
|
|
159
163
|
end
|
|
160
164
|
end
|
|
161
165
|
end
|
|
@@ -163,7 +167,12 @@ module HaveAPI::Client
|
|
|
163
167
|
# Called before defining a method named +name+ that will
|
|
164
168
|
# invoke +action+.
|
|
165
169
|
def define_method?(action, name)
|
|
166
|
-
|
|
170
|
+
method_name = name.to_sym
|
|
171
|
+
|
|
172
|
+
return false if singleton_class.public_method_defined?(method_name)
|
|
173
|
+
return false if singleton_class.protected_method_defined?(method_name)
|
|
174
|
+
return false if singleton_class.private_method_defined?(method_name, false)
|
|
175
|
+
return false if self.class.private_method_defined?(method_name, false)
|
|
167
176
|
|
|
168
177
|
true
|
|
169
178
|
end
|
|
@@ -176,6 +185,8 @@ module HaveAPI::Client
|
|
|
176
185
|
end
|
|
177
186
|
|
|
178
187
|
def define_resource(resource)
|
|
188
|
+
return unless define_resource_method?(resource._name)
|
|
189
|
+
|
|
179
190
|
define_singleton_method(resource._name) do |*args|
|
|
180
191
|
tmp = resource.dup
|
|
181
192
|
tmp.prepared_args = @prepared_args + args
|
|
@@ -183,5 +194,14 @@ module HaveAPI::Client
|
|
|
183
194
|
tmp
|
|
184
195
|
end
|
|
185
196
|
end
|
|
197
|
+
|
|
198
|
+
def define_resource_method?(name)
|
|
199
|
+
method_name = name.to_sym
|
|
200
|
+
|
|
201
|
+
!singleton_class.public_method_defined?(method_name) &&
|
|
202
|
+
!singleton_class.protected_method_defined?(method_name) &&
|
|
203
|
+
!singleton_class.private_method_defined?(method_name, false) &&
|
|
204
|
+
!self.class.private_method_defined?(method_name, false)
|
|
205
|
+
end
|
|
186
206
|
end
|
|
187
207
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
RSpec.describe HaveAPI::Client::Action do
|
|
7
|
+
let(:action_spec) do
|
|
8
|
+
{
|
|
9
|
+
path: '/v1/users/{user_id}',
|
|
10
|
+
help: '/v1/users/{user_id}?describe=action',
|
|
11
|
+
method: 'GET',
|
|
12
|
+
auth: false,
|
|
13
|
+
blocking: false,
|
|
14
|
+
aliases: [],
|
|
15
|
+
input: {
|
|
16
|
+
layout: :hash,
|
|
17
|
+
namespace: :user,
|
|
18
|
+
parameters: {}
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
layout: :hash,
|
|
22
|
+
namespace: :user,
|
|
23
|
+
parameters: {}
|
|
24
|
+
},
|
|
25
|
+
meta: {}
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
let(:path_arg) { '42?user[name]=alice&_meta[includes]=group__secret' }
|
|
30
|
+
let(:encoded_arg) do
|
|
31
|
+
'42%3Fuser%5Bname%5D%3Dalice%26_meta%5Bincludes%5D%3Dgroup__secret'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
let(:communicator) do
|
|
35
|
+
Class.new do
|
|
36
|
+
attr_reader :called_paths
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@called_paths = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def url
|
|
43
|
+
'https://api.example'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def describe_api(_version)
|
|
47
|
+
{
|
|
48
|
+
resources: {
|
|
49
|
+
users: {
|
|
50
|
+
actions: {
|
|
51
|
+
show: {
|
|
52
|
+
auth: false,
|
|
53
|
+
description: 'Show a user',
|
|
54
|
+
aliases: [],
|
|
55
|
+
blocking: false,
|
|
56
|
+
input: {
|
|
57
|
+
layout: 'hash',
|
|
58
|
+
namespace: 'user',
|
|
59
|
+
parameters: {
|
|
60
|
+
note: {
|
|
61
|
+
type: 'String',
|
|
62
|
+
nullable: false,
|
|
63
|
+
validators: {
|
|
64
|
+
present: {
|
|
65
|
+
empty: false,
|
|
66
|
+
message: 'required'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
output: {
|
|
73
|
+
layout: 'hash',
|
|
74
|
+
namespace: 'user',
|
|
75
|
+
parameters: {}
|
|
76
|
+
},
|
|
77
|
+
meta: {
|
|
78
|
+
object: nil,
|
|
79
|
+
global: nil
|
|
80
|
+
},
|
|
81
|
+
path: '/v1/users/{user_id}',
|
|
82
|
+
help: '/v1/users/{user_id}?method=GET',
|
|
83
|
+
method: 'GET'
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
resources: {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def call(action, params = {})
|
|
93
|
+
@called_paths << action.prepared_path
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
status: true,
|
|
97
|
+
response: {
|
|
98
|
+
user: {
|
|
99
|
+
path: action.prepared_path,
|
|
100
|
+
params: params
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
let(:client) do
|
|
109
|
+
HaveAPI::Client::Client.new(
|
|
110
|
+
'https://api.example',
|
|
111
|
+
communicator: communicator
|
|
112
|
+
).tap(&:setup)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'encodes constructor path arguments as path components' do
|
|
116
|
+
action = described_class.new(nil, nil, :show, action_spec, [path_arg])
|
|
117
|
+
parsed = URI.parse("https://api.example#{action.prepared_path}")
|
|
118
|
+
|
|
119
|
+
expect(action.prepared_path).to eq("/v1/users/#{encoded_arg}")
|
|
120
|
+
expect(parsed.path).to eq("/v1/users/#{encoded_arg}")
|
|
121
|
+
expect(parsed.query).to be_nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'encodes provided path arguments as path components' do
|
|
125
|
+
action = described_class.new(nil, nil, :show, action_spec, [])
|
|
126
|
+
|
|
127
|
+
action.provide_args(path_arg)
|
|
128
|
+
|
|
129
|
+
expect(action.prepared_path).to eq("/v1/users/#{encoded_arg}")
|
|
130
|
+
expect(action.prepared_help).to eq("/v1/users/#{encoded_arg}?describe=action")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'does not reuse path arguments after validation fails' do
|
|
134
|
+
expect do
|
|
135
|
+
client.users.show(42, {})
|
|
136
|
+
end.to raise_error(HaveAPI::Client::ValidationError)
|
|
137
|
+
|
|
138
|
+
expect do
|
|
139
|
+
client.users.show(note: 'allowed follow-up input')
|
|
140
|
+
end.to raise_error(ArgumentError, 'one or more object ids missing')
|
|
141
|
+
|
|
142
|
+
response = client.users.show(7, note: 'allowed follow-up input')
|
|
143
|
+
|
|
144
|
+
expect(communicator.called_paths).to eq(['/v1/users/7'])
|
|
145
|
+
expect(response[:path]).to eq('/v1/users/7')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'does not reuse path arguments after argument parsing fails' do
|
|
149
|
+
expect do
|
|
150
|
+
client.users.show(42, { note: 'valid input' }, { note: 'extra input' })
|
|
151
|
+
end.to raise_error(ArgumentError, 'too many arguments for action users#show')
|
|
152
|
+
|
|
153
|
+
expect do
|
|
154
|
+
client.users.show(note: 'allowed follow-up input')
|
|
155
|
+
end.to raise_error(ArgumentError, 'one or more object ids missing')
|
|
156
|
+
|
|
157
|
+
expect(communicator.called_paths).to eq([])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HaveAPI::Client::Authentication::Token do
|
|
6
|
+
let(:communicator) do
|
|
7
|
+
Class.new do
|
|
8
|
+
def url
|
|
9
|
+
'https://api.example'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def verify_ssl
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
end.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
let(:http_header) { 'X-HaveAPI-Auth-Token' }
|
|
19
|
+
|
|
20
|
+
let(:description) do
|
|
21
|
+
{
|
|
22
|
+
http_header: http_header,
|
|
23
|
+
query_parameter: 'auth_token',
|
|
24
|
+
resources: { token: { actions: {} } }
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context 'when sending tokens in HTTP headers' do
|
|
29
|
+
it 'uses RFC-safe token header names from the API description' do
|
|
30
|
+
http_header = 'X-HaveAPI-Auth-Token'
|
|
31
|
+
auth = described_class.new(
|
|
32
|
+
communicator,
|
|
33
|
+
description.merge(http_header: http_header),
|
|
34
|
+
token: 'secret-token'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
expect(auth.request_headers).to eq(http_header => 'secret-token')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'rejects description-controlled header names that can inject headers' do
|
|
41
|
+
http_header = "X-HaveAPI-Auth-Token\r\nX-Injected-Token"
|
|
42
|
+
auth = described_class.new(
|
|
43
|
+
communicator,
|
|
44
|
+
description.merge(http_header: http_header),
|
|
45
|
+
token: 'secret-token'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect { auth.request_headers }.to raise_error(
|
|
49
|
+
ArgumentError,
|
|
50
|
+
/invalid token authentication HTTP header name/
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'rejects header names outside the HTTP token grammar' do
|
|
55
|
+
invalid_headers = [
|
|
56
|
+
nil,
|
|
57
|
+
'',
|
|
58
|
+
'X HaveAPI Token',
|
|
59
|
+
'X-HaveAPI-Token:',
|
|
60
|
+
"X-HaveAPI-Token\n"
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
invalid_headers.each do |http_header|
|
|
64
|
+
auth = described_class.new(
|
|
65
|
+
communicator,
|
|
66
|
+
description.merge(http_header: http_header),
|
|
67
|
+
token: 'secret-token'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect { auth.request_headers }.to raise_error(ArgumentError)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
context 'when sending tokens in query parameters' do
|
|
76
|
+
let(:http_header) { "X-HaveAPI-Auth-Token\r\nX-Injected-Token" }
|
|
77
|
+
|
|
78
|
+
it 'does not use the HTTP header name from the API description' do
|
|
79
|
+
auth = described_class.new(
|
|
80
|
+
communicator,
|
|
81
|
+
description,
|
|
82
|
+
token: 'secret-token',
|
|
83
|
+
via: :query_param
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(auth.request_headers).to eq({})
|
|
87
|
+
expect(auth.request_query_params).to eq('auth_token' => 'secret-token')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'haveapi/cli'
|
|
7
|
+
|
|
8
|
+
RSpec.describe HaveAPI::CLI::Cli do
|
|
9
|
+
around do |example|
|
|
10
|
+
old_umask = File.umask(0o022)
|
|
11
|
+
|
|
12
|
+
example.run
|
|
13
|
+
ensure
|
|
14
|
+
File.umask(old_umask) if old_umask
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
allow(Dir).to receive(:home).and_return(home)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
after do
|
|
22
|
+
FileUtils.rm_rf(home)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
let(:home) { Dir.mktmpdir('haveapi-home-') }
|
|
26
|
+
let(:config_path) { File.join(home, '.haveapi-client.yml') }
|
|
27
|
+
let(:secret_token) { 'vuln75-secret-token' }
|
|
28
|
+
let(:config) do
|
|
29
|
+
{
|
|
30
|
+
servers: [
|
|
31
|
+
{
|
|
32
|
+
url: 'https://api.example',
|
|
33
|
+
auth: {
|
|
34
|
+
token: {
|
|
35
|
+
token: secret_token,
|
|
36
|
+
valid_to: 1_800_000_000
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
last_auth: :token
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cli_with_config(config)
|
|
46
|
+
described_class.allocate.tap do |cli|
|
|
47
|
+
cli.instance_variable_set(:@config, config)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'creates saved credential config files with owner-only permissions' do
|
|
52
|
+
cli_with_config(config).send(:write_config)
|
|
53
|
+
|
|
54
|
+
expect(File.read(config_path)).to include(secret_token)
|
|
55
|
+
expect(File.stat(config_path).mode & 0o777).to eq(0o600)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'narrows existing saved credential config files before rewriting them' do
|
|
59
|
+
File.write(config_path, 'previous config')
|
|
60
|
+
File.chmod(0o644, config_path)
|
|
61
|
+
|
|
62
|
+
cli_with_config(config).send(:write_config)
|
|
63
|
+
|
|
64
|
+
expect(File.read(config_path)).to include(secret_token)
|
|
65
|
+
expect(File.stat(config_path).mode & 0o777).to eq(0o600)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HaveAPI::Client::Client do
|
|
6
|
+
let(:communicator_class) do
|
|
7
|
+
Struct.new(:description, :authenticate_calls, :describe_calls) do
|
|
8
|
+
def initialize(description)
|
|
9
|
+
super(description, 0, 0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def describe_api(_version)
|
|
13
|
+
self.describe_calls += 1
|
|
14
|
+
description
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authenticate(*)
|
|
18
|
+
self.authenticate_calls += 1
|
|
19
|
+
:authenticated
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(action, _params = {})
|
|
23
|
+
{
|
|
24
|
+
status: true,
|
|
25
|
+
response: {
|
|
26
|
+
action: action.name
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def url
|
|
32
|
+
'https://api.example'
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def action_description(aliases: [])
|
|
38
|
+
{
|
|
39
|
+
auth: false,
|
|
40
|
+
description: 'test action',
|
|
41
|
+
aliases: aliases,
|
|
42
|
+
blocking: false,
|
|
43
|
+
input: {
|
|
44
|
+
layout: 'hash',
|
|
45
|
+
namespace: 'input',
|
|
46
|
+
parameters: {}
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
layout: 'hash',
|
|
50
|
+
namespace: 'output',
|
|
51
|
+
parameters: {}
|
|
52
|
+
},
|
|
53
|
+
meta: {
|
|
54
|
+
object: nil,
|
|
55
|
+
global: nil
|
|
56
|
+
},
|
|
57
|
+
path: '/v1/test',
|
|
58
|
+
help: '/v1/test?method=GET',
|
|
59
|
+
method: 'GET'
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def client_for(resources)
|
|
64
|
+
api = communicator_class.new(resources: resources)
|
|
65
|
+
client = described_class.new(
|
|
66
|
+
'https://api.example',
|
|
67
|
+
communicator: api
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
[client, api]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'does not let top-level resources replace existing client methods' do
|
|
74
|
+
client, api = client_for(
|
|
75
|
+
authenticate: {
|
|
76
|
+
actions: {},
|
|
77
|
+
resources: {}
|
|
78
|
+
},
|
|
79
|
+
setup: {
|
|
80
|
+
actions: {},
|
|
81
|
+
resources: {}
|
|
82
|
+
},
|
|
83
|
+
users: {
|
|
84
|
+
actions: {},
|
|
85
|
+
resources: {}
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(client.method(:setup).owner).to eq(described_class)
|
|
90
|
+
expect(client.authenticate(:token, token: 'secret-token')).to eq(:authenticated)
|
|
91
|
+
|
|
92
|
+
client.setup
|
|
93
|
+
|
|
94
|
+
expect(client.method(:setup).owner).to eq(described_class)
|
|
95
|
+
expect(client.authenticate(:token, token: 'secret-token')).to eq(:authenticated)
|
|
96
|
+
expect(api.authenticate_calls).to eq(2)
|
|
97
|
+
expect(client.users).to be_a(HaveAPI::Client::Resource)
|
|
98
|
+
expect(client.resources.keys).to contain_exactly(:authenticate, :setup, :users)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'does not let actions or nested resources replace resource methods' do
|
|
102
|
+
client, = client_for(
|
|
103
|
+
users: {
|
|
104
|
+
actions: {
|
|
105
|
+
inspect: action_description,
|
|
106
|
+
show: action_description(aliases: %w[resources details])
|
|
107
|
+
},
|
|
108
|
+
resources: {
|
|
109
|
+
posts: {
|
|
110
|
+
actions: {},
|
|
111
|
+
resources: {}
|
|
112
|
+
},
|
|
113
|
+
setup: {
|
|
114
|
+
actions: {},
|
|
115
|
+
resources: {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
client.setup
|
|
122
|
+
users = client.users
|
|
123
|
+
|
|
124
|
+
expect(users.method(:inspect).owner).to eq(HaveAPI::Client::Resource)
|
|
125
|
+
expect(users.resources).to be_a(Hash)
|
|
126
|
+
expect(users.method(:setup).owner).to eq(HaveAPI::Client::Resource)
|
|
127
|
+
expect(users.show).to be_a(HaveAPI::Client::Response)
|
|
128
|
+
expect(users.details).to be_a(HaveAPI::Client::Response)
|
|
129
|
+
expect(users.posts).to be_a(HaveAPI::Client::Resource)
|
|
130
|
+
expect(users.actions.keys).to contain_exactly(:inspect, :show)
|
|
131
|
+
expect(users.resources.keys).to contain_exactly(:posts, :setup)
|
|
132
|
+
end
|
|
133
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: haveapi-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.28.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakub Skokan
|
|
@@ -150,7 +150,10 @@ files:
|
|
|
150
150
|
- lib/haveapi/client/validators/presence.rb
|
|
151
151
|
- lib/haveapi/client/version.rb
|
|
152
152
|
- lib/restclient_ext/resource.rb
|
|
153
|
-
-
|
|
153
|
+
- spec/action_security_spec.rb
|
|
154
|
+
- spec/authentication_token_security_spec.rb
|
|
155
|
+
- spec/cli_security_spec.rb
|
|
156
|
+
- spec/description_method_security_spec.rb
|
|
154
157
|
- spec/integration/client_spec.rb
|
|
155
158
|
- spec/integration/typed_input_spec.rb
|
|
156
159
|
- spec/spec_helper.rb
|
data/shell.nix
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
let
|
|
2
|
-
pkgs = import <nixpkgs> {};
|
|
3
|
-
stdenv = pkgs.stdenv;
|
|
4
|
-
|
|
5
|
-
in stdenv.mkDerivation rec {
|
|
6
|
-
name = "haveapi-client";
|
|
7
|
-
|
|
8
|
-
buildInputs = with pkgs;[
|
|
9
|
-
ruby
|
|
10
|
-
git
|
|
11
|
-
openssl
|
|
12
|
-
];
|
|
13
|
-
|
|
14
|
-
shellHook = ''
|
|
15
|
-
export GEM_HOME=$(pwd)/../../.gems
|
|
16
|
-
export PATH="$GEM_HOME/bin:$PATH"
|
|
17
|
-
gem install bundler
|
|
18
|
-
bundle install
|
|
19
|
-
'';
|
|
20
|
-
}
|