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.
@@ -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