better-faraday 2.0.2 → 3.1.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/.gitignore +2 -0
- data/.rubocop.yml +40 -0
- data/.ruby-version +1 -1
- data/Gemfile +6 -0
- data/Gemfile.lock +124 -0
- data/README.md +110 -3
- data/Rakefile +8 -0
- data/better-faraday.gemspec +18 -5
- data/lib/better_faraday.rb +440 -0
- data/spec/adapters_spec.rb +104 -0
- data/spec/array_masking_spec.rb +47 -0
- data/spec/assert_status_spec.rb +71 -0
- data/spec/custom_sensitive_signs_spec.rb +46 -0
- data/spec/http_error_spec.rb +56 -0
- data/spec/inspect_truncation_spec.rb +40 -0
- data/spec/invalid_json_spec.rb +37 -0
- data/spec/logger_spec.rb +52 -0
- data/spec/middleware_spec.rb +61 -0
- data/spec/multipart_spec.rb +39 -0
- data/spec/nested_sensitive_data_spec.rb +57 -0
- data/spec/sensitive_data_signs_spec.rb +109 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/timing_inspect_spec.rb +51 -0
- metadata +130 -21
- data/lib/better-faraday.rb +0 -259
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "faraday/error"
|
|
7
|
+
require "faraday/options"
|
|
8
|
+
require "faraday/response"
|
|
9
|
+
require "logger"
|
|
10
|
+
|
|
11
|
+
# Faraday::Adapter middleware extension
|
|
12
|
+
Module.new do
|
|
13
|
+
def call(*args, **kwargs, &block)
|
|
14
|
+
environment = args.first
|
|
15
|
+
|
|
16
|
+
unless environment.nil?
|
|
17
|
+
BetterFaraday.set_value \
|
|
18
|
+
environment,
|
|
19
|
+
"environment",
|
|
20
|
+
"bf_request_sent_at",
|
|
21
|
+
Time.now.utc,
|
|
22
|
+
"Faraday::Adapter#call"
|
|
23
|
+
|
|
24
|
+
# Eagerly capture headers as a fallback BEFORE the adapter executes.
|
|
25
|
+
# If the adapter is a real network adapter (like Net::HTTP), it will overwrite
|
|
26
|
+
# these with the exact wire headers later. Test adapters will use this fallback.
|
|
27
|
+
BetterFaraday.set_value \
|
|
28
|
+
environment,
|
|
29
|
+
"environment",
|
|
30
|
+
"bf_request_headers",
|
|
31
|
+
environment.request_headers,
|
|
32
|
+
"Faraday::Adapter#call"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def save_response(*args, **kwargs, &block)
|
|
39
|
+
environment = args.first
|
|
40
|
+
|
|
41
|
+
unless environment.nil?
|
|
42
|
+
BetterFaraday.set_value \
|
|
43
|
+
environment,
|
|
44
|
+
"environment",
|
|
45
|
+
"bf_response_received_at",
|
|
46
|
+
Time.now.utc,
|
|
47
|
+
"Faraday::Adapter#save_response"
|
|
48
|
+
|
|
49
|
+
body = environment.body
|
|
50
|
+
|
|
51
|
+
# Safely read the body. We MUST rewind BEFORE reading because the
|
|
52
|
+
# HTTP adapter or test stub has likely already consumed the stream to EOF.
|
|
53
|
+
value = if body.respond_to?(:read)
|
|
54
|
+
body.rewind if body.respond_to?(:rewind)
|
|
55
|
+
content = body.read
|
|
56
|
+
body.rewind if body.respond_to?(:rewind) # Rewind again to leave it clean
|
|
57
|
+
content
|
|
58
|
+
else
|
|
59
|
+
body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
BetterFaraday.set_value \
|
|
63
|
+
environment,
|
|
64
|
+
"environment",
|
|
65
|
+
"bf_request_body",
|
|
66
|
+
value.is_a?(String) ? value : value.to_s,
|
|
67
|
+
"Faraday::Adapter#save_response"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
end.tap { |m| Faraday::Adapter.prepend(m) }
|
|
73
|
+
|
|
74
|
+
# Net::HTTP middleware extension
|
|
75
|
+
Module.new do
|
|
76
|
+
def end_transport(*args, **kwargs, &block)
|
|
77
|
+
request, response = args[0], args[1]
|
|
78
|
+
|
|
79
|
+
if !request.nil? && !response.nil?
|
|
80
|
+
headers = request.to_hash
|
|
81
|
+
headers.each_key { headers[_1] = headers[_1].join(", ") }
|
|
82
|
+
|
|
83
|
+
BetterFaraday.set_value \
|
|
84
|
+
response,
|
|
85
|
+
"response",
|
|
86
|
+
"bf_request_headers",
|
|
87
|
+
headers,
|
|
88
|
+
"Net::HTTP#end_transport"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
end.tap { |m| Net::HTTP.prepend(m) }
|
|
94
|
+
|
|
95
|
+
# Faraday::Adapter::NetHttp middleware extension
|
|
96
|
+
Module.new do
|
|
97
|
+
def perform_request(*args, **kwargs, &block)
|
|
98
|
+
# Faraday 1.x passes (http, env), Faraday 2.x passes (env)
|
|
99
|
+
environment = args.find { _1.is_a?(Faraday::Env) }
|
|
100
|
+
|
|
101
|
+
super.tap do |response|
|
|
102
|
+
if !environment.nil? && !response.nil?
|
|
103
|
+
net_headers = BetterFaraday.get_value \
|
|
104
|
+
response,
|
|
105
|
+
"response",
|
|
106
|
+
"bf_request_headers",
|
|
107
|
+
"Faraday::Adapter::NetHttp#perform_request"
|
|
108
|
+
|
|
109
|
+
# Overwrite only if Net::HTTP actually provides network headers
|
|
110
|
+
unless net_headers.nil?
|
|
111
|
+
BetterFaraday.set_value \
|
|
112
|
+
environment,
|
|
113
|
+
"environment",
|
|
114
|
+
"bf_request_headers",
|
|
115
|
+
net_headers,
|
|
116
|
+
"Faraday::Adapter::NetHttp#perform_request"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end.tap do |m|
|
|
122
|
+
Faraday::Adapter::NetHttp.prepend(m)
|
|
123
|
+
|
|
124
|
+
# Conditionally prepend for NetHttpPersistent if it is loaded (Faraday 1.x)
|
|
125
|
+
Faraday::Adapter::NetHttpPersistent.prepend(m) if defined?(Faraday::Adapter::NetHttpPersistent)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
module Faraday
|
|
129
|
+
class Env
|
|
130
|
+
attr_reader :bf_request_headers, :bf_request_body, :bf_request_sent_at, :bf_response_received_at
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class Response
|
|
134
|
+
def assert_status!(code_or_range)
|
|
135
|
+
within_range = if code_or_range.is_a?(Range)
|
|
136
|
+
code_or_range.include?(status)
|
|
137
|
+
else
|
|
138
|
+
status == code_or_range
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return self if within_range
|
|
142
|
+
|
|
143
|
+
klass_name = "HTTP#{status}"
|
|
144
|
+
|
|
145
|
+
klass = if BetterFaraday.const_defined?(klass_name)
|
|
146
|
+
BetterFaraday.const_get(klass_name)
|
|
147
|
+
elsif status_3xx?
|
|
148
|
+
BetterFaraday::HTTP3xx
|
|
149
|
+
elsif status_4xx?
|
|
150
|
+
BetterFaraday::HTTP4xx
|
|
151
|
+
elsif status_5xx?
|
|
152
|
+
BetterFaraday::HTTP5xx
|
|
153
|
+
else
|
|
154
|
+
BetterFaraday::HTTPError
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
raise klass, self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def assert_2xx!
|
|
161
|
+
assert_status!(200..299)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def assert_200!
|
|
165
|
+
assert_status!(200)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def status_2xx?
|
|
169
|
+
status >= 200 && status <= 299
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def status_3xx?
|
|
173
|
+
status >= 300 && status <= 399
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def status_4xx?
|
|
177
|
+
status >= 400 && status <= 499
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def status_5xx?
|
|
181
|
+
status >= 500 && status <= 599
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def inspect
|
|
185
|
+
@inspection = bf_inspect if @inspection.nil?
|
|
186
|
+
@inspection.dup
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def bf_truncate(str, limit = 2048, omission = "... (truncated)")
|
|
192
|
+
return str if str.length <= limit
|
|
193
|
+
|
|
194
|
+
str[0, limit - omission.length] + omission
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def bf_json_parse(json)
|
|
198
|
+
return nil unless json.is_a?(String)
|
|
199
|
+
|
|
200
|
+
data = ::JSON.parse(json)
|
|
201
|
+
data if data.is_a?(Hash) || data.is_a?(Array)
|
|
202
|
+
rescue ::JSON::ParserError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def bf_json_dump(data)
|
|
207
|
+
::JSON.generate(data, space: " ", object_nl: " ", array_nl: " ")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def bf_dump(data)
|
|
211
|
+
# 1) String#inspect returns \x{XXXX} for the encoding other than Unicode
|
|
212
|
+
# 2) [1..-2] removes leading and trailing " added by String#inspect
|
|
213
|
+
# 3) gsub(/\\"/, "\"") unescapes "
|
|
214
|
+
data.inspect.gsub(/\\"/, "\"")[1..-2]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def bf_protect_data(data)
|
|
218
|
+
return data.map { bf_protect_data(_1) } if data.is_a?(Array)
|
|
219
|
+
return data unless data.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
signs = BetterFaraday.sensitive_data_signs
|
|
222
|
+
|
|
223
|
+
data.each_with_object({}) do |(key, value), memo|
|
|
224
|
+
normalized_key = key.to_s
|
|
225
|
+
|
|
226
|
+
memo[key] = if signs.any? { |r| normalized_key.match?(r) }
|
|
227
|
+
"SECRET"
|
|
228
|
+
else
|
|
229
|
+
bf_protect_data(value)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def bf_inspect
|
|
235
|
+
request_headers_hash = env.bf_request_headers
|
|
236
|
+
if !request_headers_hash.is_a?(Hash) && request_headers_hash.respond_to?(:to_hash)
|
|
237
|
+
request_headers_hash = request_headers_hash.to_hash
|
|
238
|
+
end
|
|
239
|
+
request_headers_hash = {} if request_headers_hash.nil?
|
|
240
|
+
|
|
241
|
+
request_body = env.bf_request_body.then { _1.is_a?(String) ? _1 : _1.to_s }
|
|
242
|
+
request_byte_count = request_body.bytesize
|
|
243
|
+
|
|
244
|
+
request_is_json = request_headers_hash.any? do |k, v|
|
|
245
|
+
k.to_s.downcase == "content-type" && v.to_s.match?(%r{\b(?:application|text)/json\b}i)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
request_json = bf_json_parse(request_body)&.then { bf_json_dump(bf_protect_data(_1)) } if request_is_json
|
|
249
|
+
|
|
250
|
+
request_byte_label = request_byte_count == 1 ? "byte" : "bytes"
|
|
251
|
+
|
|
252
|
+
response_headers_hash = env.response_headers
|
|
253
|
+
if !response_headers_hash.is_a?(Hash) && response_headers_hash.respond_to?(:to_hash)
|
|
254
|
+
response_headers_hash = response_headers_hash.to_hash
|
|
255
|
+
end
|
|
256
|
+
response_headers_hash = {} if response_headers_hash.nil?
|
|
257
|
+
|
|
258
|
+
response_body = env.body.then { _1.is_a?(String) ? _1 : _1.to_s }
|
|
259
|
+
response_byte_count = response_body.bytesize
|
|
260
|
+
|
|
261
|
+
response_is_json = response_headers_hash.any? do |k, v|
|
|
262
|
+
k.to_s.downcase == "content-type" && v.to_s.match?(%r{\b(?:application|text)/json\b}i)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
response_json = bf_json_parse(response_body)&.then { bf_json_dump(bf_protect_data(_1)) } if response_is_json
|
|
266
|
+
|
|
267
|
+
response_byte_label = response_byte_count == 1 ? "byte" : "bytes"
|
|
268
|
+
|
|
269
|
+
lines = [
|
|
270
|
+
"-- HTTP #{status} #{reason_phrase} --".gsub(/\s+/, " ").upcase,
|
|
271
|
+
"",
|
|
272
|
+
"-- Request URL --",
|
|
273
|
+
env.url.to_s,
|
|
274
|
+
"",
|
|
275
|
+
"-- Request Method --",
|
|
276
|
+
env.method.to_s.upcase,
|
|
277
|
+
"",
|
|
278
|
+
"-- Request Headers --",
|
|
279
|
+
if !request_headers_hash.nil?
|
|
280
|
+
bf_json_dump(bf_protect_data(request_headers_hash))
|
|
281
|
+
else
|
|
282
|
+
env.bf_request_headers.to_s.inspect.gsub(/\\"/, "\"")[1..-2]
|
|
283
|
+
end.then { bf_truncate(_1) },
|
|
284
|
+
"",
|
|
285
|
+
|
|
286
|
+
%[-- Request Body (#{request_byte_count} #{request_byte_label}) --],
|
|
287
|
+
if !request_json.nil?
|
|
288
|
+
request_json
|
|
289
|
+
else
|
|
290
|
+
bf_dump(request_body)
|
|
291
|
+
end.then { bf_truncate(_1) },
|
|
292
|
+
"",
|
|
293
|
+
|
|
294
|
+
"-- Request Sent At --",
|
|
295
|
+
env.bf_request_sent_at.nil? ? "N/A" : env.bf_request_sent_at.strftime("%Y-%m-%d %H:%M:%S.%3N UTC"),
|
|
296
|
+
"",
|
|
297
|
+
|
|
298
|
+
"-- Response Headers --",
|
|
299
|
+
if !response_headers_hash.nil?
|
|
300
|
+
bf_json_dump(bf_protect_data(response_headers_hash))
|
|
301
|
+
else
|
|
302
|
+
bf_dump(env.response_headers)
|
|
303
|
+
end.then { bf_truncate(_1) },
|
|
304
|
+
"",
|
|
305
|
+
|
|
306
|
+
%[-- Response Body (#{response_byte_count} #{response_byte_label}) --],
|
|
307
|
+
if !response_json.nil?
|
|
308
|
+
response_json
|
|
309
|
+
else
|
|
310
|
+
bf_dump(response_body)
|
|
311
|
+
end.then { bf_truncate(_1) },
|
|
312
|
+
""
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
if env.bf_response_received_at && env.bf_request_sent_at
|
|
316
|
+
lines.concat [
|
|
317
|
+
"-- Response Received At --",
|
|
318
|
+
env.bf_response_received_at.strftime("%Y-%m-%d %H:%M:%S.%3N UTC"),
|
|
319
|
+
"",
|
|
320
|
+
"-- Response Received In --",
|
|
321
|
+
"#{((env.bf_response_received_at.to_f - env.bf_request_sent_at.to_f) * 1000.0).ceil(3)}ms",
|
|
322
|
+
""
|
|
323
|
+
]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
lines.join("\n").freeze
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
module BetterFaraday
|
|
332
|
+
class HTTPError < Faraday::Error
|
|
333
|
+
def initialize(response)
|
|
334
|
+
super(response.inspect, response)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def inspect
|
|
338
|
+
%(#{self.class}\n\n#{response.inspect})
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
class HTTP3xx < HTTPError; end
|
|
343
|
+
|
|
344
|
+
class HTTP300 < HTTP3xx; end
|
|
345
|
+
|
|
346
|
+
class HTTP301 < HTTP3xx; end
|
|
347
|
+
|
|
348
|
+
class HTTP302 < HTTP3xx; end
|
|
349
|
+
|
|
350
|
+
class HTTP303 < HTTP3xx; end
|
|
351
|
+
|
|
352
|
+
class HTTP304 < HTTP3xx; end
|
|
353
|
+
|
|
354
|
+
class HTTP307 < HTTP3xx; end
|
|
355
|
+
|
|
356
|
+
class HTTP308 < HTTP3xx; end
|
|
357
|
+
|
|
358
|
+
class HTTP4xx < HTTPError; end
|
|
359
|
+
|
|
360
|
+
class HTTP400 < HTTP4xx; end
|
|
361
|
+
|
|
362
|
+
class HTTP401 < HTTP4xx; end
|
|
363
|
+
|
|
364
|
+
class HTTP403 < HTTP4xx; end
|
|
365
|
+
|
|
366
|
+
class HTTP404 < HTTP4xx; end
|
|
367
|
+
|
|
368
|
+
class HTTP408 < HTTP4xx; end
|
|
369
|
+
|
|
370
|
+
class HTTP422 < HTTP4xx; end
|
|
371
|
+
|
|
372
|
+
class HTTP429 < HTTP4xx; end
|
|
373
|
+
|
|
374
|
+
class HTTP5xx < HTTPError; end
|
|
375
|
+
|
|
376
|
+
class HTTP500 < HTTP5xx; end
|
|
377
|
+
|
|
378
|
+
class HTTP502 < HTTP5xx; end
|
|
379
|
+
|
|
380
|
+
class HTTP503 < HTTP5xx; end
|
|
381
|
+
|
|
382
|
+
class << self
|
|
383
|
+
attr_accessor :sensitive_data_signs, :logger
|
|
384
|
+
|
|
385
|
+
def get_value(object, object_name, field_name, location)
|
|
386
|
+
is_set = object.instance_variable_get("@#{field_name}_set")
|
|
387
|
+
value = object.instance_variable_get("@#{field_name}")
|
|
388
|
+
|
|
389
|
+
logger.debug do
|
|
390
|
+
%([#{location}] Getting #{object_name}.#{field_name}: #{value.inspect}#{' (not set)' unless is_set})
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
value
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def set_value(object, object_name, field_name, new_value, location)
|
|
397
|
+
is_set = object.instance_variable_get("@#{field_name}_set")
|
|
398
|
+
|
|
399
|
+
logger.debug do
|
|
400
|
+
%([#{location}] #{is_set ? 'Overwriting' : 'Setting'} #{object_name}.#{field_name} = #{new_value.inspect})
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
object.instance_variable_set("@#{field_name}", new_value)
|
|
404
|
+
object.instance_variable_set("@#{field_name}_set", true)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Local variable to DRY up regular expressions for typical key formats
|
|
409
|
+
# (snake_case, kebab-case, spaced, or camelCase with no separator)
|
|
410
|
+
separator = "[_ -]?"
|
|
411
|
+
|
|
412
|
+
self.sensitive_data_signs = [
|
|
413
|
+
# Passwords and passphrases
|
|
414
|
+
/pass#{separator}(?:word|phrase)/i,
|
|
415
|
+
# Authorization headers or keys
|
|
416
|
+
/authorization/i,
|
|
417
|
+
# Secrets (client_secret, app_secret, etc.)
|
|
418
|
+
/secret/i,
|
|
419
|
+
# Various tokens (access_token, refresh_token, csrf_token, bearer_token, auth_token, or just token)
|
|
420
|
+
/(?:access|refresh|auth|bearer|csrf|authorization)?#{separator}token/i,
|
|
421
|
+
# API keys (api_key, apikey, x_api_key)
|
|
422
|
+
/api#{separator}key/i,
|
|
423
|
+
# Private keys
|
|
424
|
+
/private#{separator}key/i,
|
|
425
|
+
# Crypto mnemonics
|
|
426
|
+
/mnemonic/i,
|
|
427
|
+
# Session IDs
|
|
428
|
+
/session#{separator}id/i,
|
|
429
|
+
# Credit card CVV/CVC
|
|
430
|
+
/cv[vc]/i,
|
|
431
|
+
# Credit card numbers
|
|
432
|
+
/(?:credit|bank)?#{separator}card#{separator}number/i,
|
|
433
|
+
# Crypto seed phrases
|
|
434
|
+
/seed#{separator}phrase/i,
|
|
435
|
+
# JWT
|
|
436
|
+
/jwt/i
|
|
437
|
+
].each(&:freeze)
|
|
438
|
+
|
|
439
|
+
self.logger = Logger.new(File::NULL, level: Logger::WARN)
|
|
440
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require "faraday/net_http_persistent"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# Ignore if not available in this Faraday version
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
RSpec.describe "Faraday Adapters Integration" do
|
|
13
|
+
before(:all) do
|
|
14
|
+
# Spinning up a real local server on a random port for Net::HTTP tests
|
|
15
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
16
|
+
@port = @server.addr[1]
|
|
17
|
+
|
|
18
|
+
@server_thread = Thread.new do
|
|
19
|
+
loop do
|
|
20
|
+
client = @server.accept
|
|
21
|
+
# Read the request up to the empty line (the end of the HTTP headers)
|
|
22
|
+
while (line = client.gets)
|
|
23
|
+
break if line == "\r\n"
|
|
24
|
+
end
|
|
25
|
+
# We respond with a success status
|
|
26
|
+
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nOK"
|
|
27
|
+
client.close
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
after(:all) do
|
|
33
|
+
@server_thread.kill
|
|
34
|
+
@server.close
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
let(:url) { "http://127.0.0.1:#{@port}/test" }
|
|
38
|
+
|
|
39
|
+
shared_examples "an adapter capturing headers" do |adapter_name|
|
|
40
|
+
it "captures request headers and execution times" do
|
|
41
|
+
# Allowing real local requests for WebMock
|
|
42
|
+
WebMock.allow_net_connect!
|
|
43
|
+
|
|
44
|
+
connection = Faraday.new(url: url) do |builder|
|
|
45
|
+
builder.adapter adapter_name
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
response = connection.get do |req|
|
|
49
|
+
req.headers["X-Custom-Test"] = "Hello"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
env = response.env
|
|
53
|
+
|
|
54
|
+
expect(env.bf_request_sent_at).to be_a(Time)
|
|
55
|
+
expect(env.bf_response_received_at).to be_a(Time)
|
|
56
|
+
|
|
57
|
+
# Headers must be a hash
|
|
58
|
+
expect(env.bf_request_headers).to be_a(Hash)
|
|
59
|
+
|
|
60
|
+
# We verify that our custom header has been received
|
|
61
|
+
# We use `.match?` or `downcase`, since `Net::HTTP` might change the case
|
|
62
|
+
header_keys = env.bf_request_headers.keys.map(&:downcase)
|
|
63
|
+
expect(header_keys).to include("x-custom-test")
|
|
64
|
+
|
|
65
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe "Net::HTTP Adapter" do
|
|
70
|
+
it_behaves_like "an adapter capturing headers", :net_http
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "Net::HTTP::Persistent Adapter" do
|
|
74
|
+
before do
|
|
75
|
+
skip "Adapter not available" unless Faraday::Adapter.const_defined?(:NetHttpPersistent)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it_behaves_like "an adapter capturing headers", :net_http_persistent
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe "Test Adapter (No Net::HTTP)" do
|
|
82
|
+
it "relies on the fallback mechanism from Adapter#call" do
|
|
83
|
+
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
|
84
|
+
stub.get("/test") { [200, { "Content-Type" => "text/plain" }, "OK"] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
connection = Faraday.new do |builder|
|
|
88
|
+
builder.adapter :test, stubs
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
response = connection.get("/test") do |req|
|
|
92
|
+
req.headers["X-Test-Adapter-Header"] = "Mocked"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
env = response.env
|
|
96
|
+
|
|
97
|
+
expect(env.bf_request_sent_at).to be_a(Time)
|
|
98
|
+
expect(env.bf_response_received_at).to be_a(Time)
|
|
99
|
+
|
|
100
|
+
expect(env.bf_request_headers).to be_a(Hash)
|
|
101
|
+
expect(env.bf_request_headers["X-Test-Adapter-Header"]).to eq("Mocked")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "BetterFaraday Array Masking" do
|
|
6
|
+
let(:array_body) do
|
|
7
|
+
[
|
|
8
|
+
{ user_id: 1, password: "secret_password_1", public_info: "user_a" },
|
|
9
|
+
{ user_id: 2, password: "secret_password_2", public_info: "user_b" }
|
|
10
|
+
].to_json
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
let(:env) do
|
|
14
|
+
Faraday::Env.from(
|
|
15
|
+
method: :post,
|
|
16
|
+
url: URI("https://api.example.com/batch"),
|
|
17
|
+
status: 200,
|
|
18
|
+
request_headers: { "Content-Type" => "application/json" },
|
|
19
|
+
request_body: array_body,
|
|
20
|
+
response_headers: { "Content-Type" => "application/json" },
|
|
21
|
+
body: array_body
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
let(:response) { Faraday::Response.new(env) }
|
|
26
|
+
|
|
27
|
+
before do
|
|
28
|
+
response.env.instance_variable_set(:@bf_request_headers, { "Content-Type" => "application/json" })
|
|
29
|
+
response.env.instance_variable_set(:@bf_request_body, array_body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "masks sensitive keys inside arrays of hashes" do
|
|
33
|
+
inspection = response.inspect
|
|
34
|
+
|
|
35
|
+
# Sensitive data is masked in all array elements
|
|
36
|
+
expect(inspection).to include('"password": "SECRET"')
|
|
37
|
+
|
|
38
|
+
# Original sensitive strings should not leak
|
|
39
|
+
expect(inspection).not_to include("secret_password_1")
|
|
40
|
+
expect(inspection).not_to include("secret_password_2")
|
|
41
|
+
|
|
42
|
+
# Safe data remains untouched
|
|
43
|
+
expect(inspection).to include('"user_id": 1')
|
|
44
|
+
expect(inspection).to include('"user_id": 2')
|
|
45
|
+
expect(inspection).to include('"public_info": "user_a"')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "BetterFaraday Response Assertions" do
|
|
6
|
+
let(:conn) { Faraday.new(url: "https://api.example.com") }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
stub_request(:get, "https://api.example.com/200").to_return(status: 200, body: "OK")
|
|
10
|
+
stub_request(:get, "https://api.example.com/204").to_return(status: 204, body: "")
|
|
11
|
+
stub_request(:get, "https://api.example.com/302").to_return(status: 302, body: "Found")
|
|
12
|
+
stub_request(:get, "https://api.example.com/404").to_return(status: 404, body: "Not Found")
|
|
13
|
+
stub_request(:get, "https://api.example.com/422").to_return(status: 422, body: "Unprocessable")
|
|
14
|
+
stub_request(:get, "https://api.example.com/500").to_return(status: 500, body: "Server Error")
|
|
15
|
+
stub_request(:get, "https://api.example.com/599").to_return(status: 599, body: "Unknown Error")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "#assert_200!" do
|
|
19
|
+
it "returns the response if status is 200" do
|
|
20
|
+
response = conn.get("/200")
|
|
21
|
+
expect(response.assert_200!).to eq(response)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "raises HTTPError if status is not 200" do
|
|
25
|
+
response = conn.get("/204")
|
|
26
|
+
expect { response.assert_200! }.to raise_error(BetterFaraday::HTTPError)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe "#assert_2xx!" do
|
|
31
|
+
it "returns the response if status is within 200..299" do
|
|
32
|
+
expect(conn.get("/200").assert_2xx!).to be_a(Faraday::Response)
|
|
33
|
+
expect(conn.get("/204").assert_2xx!).to be_a(Faraday::Response)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "raises HTTP302 for 302 status" do
|
|
37
|
+
response = conn.get("/302")
|
|
38
|
+
expect { response.assert_2xx! }.to raise_error(BetterFaraday::HTTP302)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe "#assert_status!" do
|
|
43
|
+
it "accepts a range of statuses" do
|
|
44
|
+
response = conn.get("/404")
|
|
45
|
+
expect(response.assert_status!(400..499)).to eq(response)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "accepts a single integer status" do
|
|
49
|
+
response = conn.get("/422")
|
|
50
|
+
expect(response.assert_status!(422)).to eq(response)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "raises the exact mapped HTTP4xx error" do
|
|
54
|
+
response = conn.get("/404")
|
|
55
|
+
expect { response.assert_status!(200) }.to raise_error(BetterFaraday::HTTP404)
|
|
56
|
+
|
|
57
|
+
response_422 = conn.get("/422")
|
|
58
|
+
expect { response_422.assert_status!(200) }.to raise_error(BetterFaraday::HTTP422)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "raises the exact mapped HTTP500 error" do
|
|
62
|
+
response = conn.get("/500")
|
|
63
|
+
expect { response.assert_status!(200) }.to raise_error(BetterFaraday::HTTP500)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "falls back to the generic HTTP5xx error for unknown 5xx statuses" do
|
|
67
|
+
response = conn.get("/599")
|
|
68
|
+
expect { response.assert_status!(200) }.to raise_error(BetterFaraday::HTTP5xx)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "BetterFaraday Custom Sensitive Data Signs" do
|
|
6
|
+
let(:custom_regex) { /super_secret_custom_key/i }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
@original_signs = BetterFaraday.sensitive_data_signs
|
|
10
|
+
|
|
11
|
+
BetterFaraday.sensitive_data_signs = (@original_signs + [custom_regex]).freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
after do
|
|
15
|
+
BetterFaraday.sensitive_data_signs = @original_signs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "masks custom keys defined by the user" do
|
|
19
|
+
headers = {
|
|
20
|
+
"Super-Secret-Custom-Key" => "hidden_header_value",
|
|
21
|
+
"Content-Type" => "application/json"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
env = Faraday::Env.from(
|
|
25
|
+
method: :post,
|
|
26
|
+
url: URI("https://api.example.com/custom"),
|
|
27
|
+
status: 200,
|
|
28
|
+
request_headers: headers,
|
|
29
|
+
request_body: { super_secret_custom_key: "hidden_body_value", safe_key: "visible" }.to_json
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
response = Faraday::Response.new(env)
|
|
33
|
+
response.env.instance_variable_set(:@bf_request_headers, headers)
|
|
34
|
+
response.env.instance_variable_set(:@bf_request_body, { super_secret_custom_key: "hidden_body_value", safe_key: "visible" }.to_json)
|
|
35
|
+
|
|
36
|
+
inspection = response.inspect
|
|
37
|
+
|
|
38
|
+
expect(inspection).to include('"Super-Secret-Custom-Key": "SECRET"')
|
|
39
|
+
expect(inspection).to include('"super_secret_custom_key": "SECRET"')
|
|
40
|
+
|
|
41
|
+
expect(inspection).not_to include("hidden_header_value")
|
|
42
|
+
expect(inspection).not_to include("hidden_body_value")
|
|
43
|
+
|
|
44
|
+
expect(inspection).to include('"safe_key": "visible"')
|
|
45
|
+
end
|
|
46
|
+
end
|