http 5.3.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -41
  3. data/LICENSE.txt +1 -1
  4. data/README.md +110 -13
  5. data/UPGRADING.md +491 -0
  6. data/http.gemspec +32 -29
  7. data/lib/http/base64.rb +11 -1
  8. data/lib/http/chainable/helpers.rb +62 -0
  9. data/lib/http/chainable/verbs.rb +136 -0
  10. data/lib/http/chainable.rb +232 -136
  11. data/lib/http/client.rb +158 -127
  12. data/lib/http/connection/internals.rb +141 -0
  13. data/lib/http/connection.rb +126 -97
  14. data/lib/http/content_type.rb +61 -6
  15. data/lib/http/errors.rb +25 -1
  16. data/lib/http/feature.rb +65 -5
  17. data/lib/http/features/auto_deflate.rb +124 -17
  18. data/lib/http/features/auto_inflate.rb +38 -15
  19. data/lib/http/features/caching/entry.rb +178 -0
  20. data/lib/http/features/caching/in_memory_store.rb +63 -0
  21. data/lib/http/features/caching.rb +216 -0
  22. data/lib/http/features/digest_auth.rb +234 -0
  23. data/lib/http/features/instrumentation.rb +97 -17
  24. data/lib/http/features/logging.rb +183 -5
  25. data/lib/http/features/normalize_uri.rb +17 -0
  26. data/lib/http/features/raise_error.rb +18 -3
  27. data/lib/http/form_data/composite_io.rb +106 -0
  28. data/lib/http/form_data/file.rb +95 -0
  29. data/lib/http/form_data/multipart/param.rb +62 -0
  30. data/lib/http/form_data/multipart.rb +106 -0
  31. data/lib/http/form_data/part.rb +52 -0
  32. data/lib/http/form_data/readable.rb +58 -0
  33. data/lib/http/form_data/urlencoded.rb +175 -0
  34. data/lib/http/form_data/version.rb +8 -0
  35. data/lib/http/form_data.rb +102 -0
  36. data/lib/http/headers/known.rb +3 -0
  37. data/lib/http/headers/normalizer.rb +17 -36
  38. data/lib/http/headers.rb +172 -65
  39. data/lib/http/mime_type/adapter.rb +24 -9
  40. data/lib/http/mime_type/json.rb +19 -4
  41. data/lib/http/mime_type.rb +21 -3
  42. data/lib/http/options/definitions.rb +189 -0
  43. data/lib/http/options.rb +172 -125
  44. data/lib/http/redirector.rb +80 -75
  45. data/lib/http/request/body.rb +87 -6
  46. data/lib/http/request/builder.rb +184 -0
  47. data/lib/http/request/proxy.rb +83 -0
  48. data/lib/http/request/writer.rb +76 -16
  49. data/lib/http/request.rb +214 -98
  50. data/lib/http/response/body.rb +103 -18
  51. data/lib/http/response/inflater.rb +35 -7
  52. data/lib/http/response/parser.rb +98 -4
  53. data/lib/http/response/status/reasons.rb +2 -4
  54. data/lib/http/response/status.rb +141 -31
  55. data/lib/http/response.rb +219 -61
  56. data/lib/http/retriable/delay_calculator.rb +38 -11
  57. data/lib/http/retriable/errors.rb +21 -0
  58. data/lib/http/retriable/performer.rb +82 -38
  59. data/lib/http/session.rb +280 -0
  60. data/lib/http/timeout/global.rb +147 -34
  61. data/lib/http/timeout/null.rb +155 -9
  62. data/lib/http/timeout/per_operation.rb +139 -18
  63. data/lib/http/uri/normalizer.rb +82 -0
  64. data/lib/http/uri/parsing.rb +182 -0
  65. data/lib/http/uri.rb +289 -124
  66. data/lib/http/version.rb +2 -1
  67. data/lib/http.rb +11 -2
  68. data/sig/deps.rbs +122 -0
  69. data/sig/http.rbs +1619 -0
  70. data/test/http/base64_test.rb +28 -0
  71. data/test/http/client_test.rb +739 -0
  72. data/test/http/connection_test.rb +1533 -0
  73. data/test/http/content_type_test.rb +190 -0
  74. data/test/http/errors_test.rb +28 -0
  75. data/test/http/feature_test.rb +49 -0
  76. data/test/http/features/auto_deflate_test.rb +317 -0
  77. data/test/http/features/auto_inflate_test.rb +213 -0
  78. data/test/http/features/caching_test.rb +942 -0
  79. data/test/http/features/digest_auth_test.rb +996 -0
  80. data/test/http/features/instrumentation_test.rb +246 -0
  81. data/test/http/features/logging_test.rb +654 -0
  82. data/test/http/features/normalize_uri_test.rb +41 -0
  83. data/test/http/features/raise_error_test.rb +77 -0
  84. data/test/http/form_data/composite_io_test.rb +215 -0
  85. data/test/http/form_data/file_test.rb +255 -0
  86. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  87. data/test/http/form_data/multipart_test.rb +303 -0
  88. data/test/http/form_data/part_test.rb +90 -0
  89. data/test/http/form_data/urlencoded_test.rb +164 -0
  90. data/test/http/form_data_test.rb +232 -0
  91. data/test/http/headers/normalizer_test.rb +93 -0
  92. data/test/http/headers_test.rb +888 -0
  93. data/test/http/mime_type/json_test.rb +39 -0
  94. data/test/http/mime_type_test.rb +150 -0
  95. data/test/http/options/base_uri_test.rb +148 -0
  96. data/test/http/options/body_test.rb +21 -0
  97. data/test/http/options/features_test.rb +38 -0
  98. data/test/http/options/form_test.rb +21 -0
  99. data/test/http/options/headers_test.rb +32 -0
  100. data/test/http/options/json_test.rb +21 -0
  101. data/test/http/options/merge_test.rb +78 -0
  102. data/test/http/options/new_test.rb +37 -0
  103. data/test/http/options/proxy_test.rb +32 -0
  104. data/test/http/options_test.rb +575 -0
  105. data/test/http/redirector_test.rb +639 -0
  106. data/test/http/request/body_test.rb +318 -0
  107. data/test/http/request/builder_test.rb +623 -0
  108. data/test/http/request/writer_test.rb +391 -0
  109. data/test/http/request_test.rb +1733 -0
  110. data/test/http/response/body_test.rb +292 -0
  111. data/test/http/response/parser_test.rb +105 -0
  112. data/test/http/response/status_test.rb +322 -0
  113. data/test/http/response_test.rb +502 -0
  114. data/test/http/retriable/delay_calculator_test.rb +194 -0
  115. data/test/http/retriable/errors_test.rb +71 -0
  116. data/test/http/retriable/performer_test.rb +551 -0
  117. data/test/http/session_test.rb +424 -0
  118. data/test/http/timeout/global_test.rb +239 -0
  119. data/test/http/timeout/null_test.rb +218 -0
  120. data/test/http/timeout/per_operation_test.rb +220 -0
  121. data/test/http/uri/normalizer_test.rb +89 -0
  122. data/test/http/uri_test.rb +1140 -0
  123. data/test/http/version_test.rb +15 -0
  124. data/test/http_test.rb +818 -0
  125. data/test/regression_tests.rb +27 -0
  126. data/test/support/dummy_server/encoding_routes.rb +47 -0
  127. data/test/support/dummy_server/routes.rb +201 -0
  128. data/test/support/dummy_server/servlet.rb +81 -0
  129. data/test/support/dummy_server.rb +200 -0
  130. data/{spec → test}/support/fakeio.rb +2 -2
  131. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  132. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  133. data/test/support/http_handling_shared.rb +11 -0
  134. data/test/support/proxy_server.rb +207 -0
  135. data/test/support/servers/runner.rb +67 -0
  136. data/{spec → test}/support/simplecov.rb +11 -2
  137. data/test/support/ssl_helper.rb +108 -0
  138. data/test/test_helper.rb +38 -0
  139. metadata +108 -168
  140. data/.github/workflows/ci.yml +0 -67
  141. data/.gitignore +0 -15
  142. data/.rspec +0 -1
  143. data/.rubocop/layout.yml +0 -8
  144. data/.rubocop/metrics.yml +0 -4
  145. data/.rubocop/rspec.yml +0 -9
  146. data/.rubocop/style.yml +0 -32
  147. data/.rubocop.yml +0 -11
  148. data/.rubocop_todo.yml +0 -219
  149. data/.yardopts +0 -2
  150. data/CHANGES_OLD.md +0 -1002
  151. data/Gemfile +0 -51
  152. data/Guardfile +0 -18
  153. data/Rakefile +0 -64
  154. data/lib/http/headers/mixin.rb +0 -34
  155. data/lib/http/retriable/client.rb +0 -37
  156. data/logo.png +0 -0
  157. data/spec/lib/http/client_spec.rb +0 -556
  158. data/spec/lib/http/connection_spec.rb +0 -88
  159. data/spec/lib/http/content_type_spec.rb +0 -47
  160. data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
  161. data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
  162. data/spec/lib/http/features/instrumentation_spec.rb +0 -81
  163. data/spec/lib/http/features/logging_spec.rb +0 -65
  164. data/spec/lib/http/features/raise_error_spec.rb +0 -62
  165. data/spec/lib/http/headers/mixin_spec.rb +0 -36
  166. data/spec/lib/http/headers/normalizer_spec.rb +0 -52
  167. data/spec/lib/http/headers_spec.rb +0 -527
  168. data/spec/lib/http/options/body_spec.rb +0 -15
  169. data/spec/lib/http/options/features_spec.rb +0 -33
  170. data/spec/lib/http/options/form_spec.rb +0 -15
  171. data/spec/lib/http/options/headers_spec.rb +0 -24
  172. data/spec/lib/http/options/json_spec.rb +0 -15
  173. data/spec/lib/http/options/merge_spec.rb +0 -68
  174. data/spec/lib/http/options/new_spec.rb +0 -30
  175. data/spec/lib/http/options/proxy_spec.rb +0 -20
  176. data/spec/lib/http/options_spec.rb +0 -13
  177. data/spec/lib/http/redirector_spec.rb +0 -530
  178. data/spec/lib/http/request/body_spec.rb +0 -211
  179. data/spec/lib/http/request/writer_spec.rb +0 -121
  180. data/spec/lib/http/request_spec.rb +0 -234
  181. data/spec/lib/http/response/body_spec.rb +0 -85
  182. data/spec/lib/http/response/parser_spec.rb +0 -74
  183. data/spec/lib/http/response/status_spec.rb +0 -253
  184. data/spec/lib/http/response_spec.rb +0 -262
  185. data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
  186. data/spec/lib/http/retriable/performer_spec.rb +0 -302
  187. data/spec/lib/http/uri/normalizer_spec.rb +0 -95
  188. data/spec/lib/http/uri_spec.rb +0 -71
  189. data/spec/lib/http_spec.rb +0 -535
  190. data/spec/regression_specs.rb +0 -24
  191. data/spec/spec_helper.rb +0 -89
  192. data/spec/support/black_hole.rb +0 -13
  193. data/spec/support/dummy_server/servlet.rb +0 -203
  194. data/spec/support/dummy_server.rb +0 -44
  195. data/spec/support/fuubar.rb +0 -21
  196. data/spec/support/http_handling_shared.rb +0 -190
  197. data/spec/support/proxy_server.rb +0 -39
  198. data/spec/support/servers/config.rb +0 -11
  199. data/spec/support/servers/runner.rb +0 -19
  200. data/spec/support/ssl_helper.rb +0 -104
  201. /data/{spec → test}/support/capture_warning.rb +0 -0
@@ -0,0 +1,996 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "securerandom"
5
+
6
+ class HTTPFeaturesDigestAuthTest < Minitest::Test
7
+ cover "HTTP::Features::DigestAuth*"
8
+
9
+ def feature
10
+ @feature ||= HTTP::Features::DigestAuth.new(user: "admin", pass: "secret")
11
+ end
12
+
13
+ def connection
14
+ @connection ||= fake
15
+ end
16
+
17
+ def request
18
+ @request ||= HTTP::Request.new(
19
+ verb: :get,
20
+ uri: "https://example.com/protected",
21
+ headers: { "Accept" => "text/html" }
22
+ )
23
+ end
24
+
25
+ def build_response(status:, headers: {})
26
+ HTTP::Response.new(
27
+ version: "1.1",
28
+ status: status,
29
+ headers: headers,
30
+ body: "",
31
+ request: request
32
+ )
33
+ end
34
+
35
+ # Helper to perform a digest challenge round-trip and capture the retried request
36
+ def perform_digest_challenge(feat, req, challenge_header)
37
+ retried_request = nil
38
+ call_count = 0
39
+
40
+ challenge_resp = HTTP::Response.new(
41
+ version: "1.1", status: 401, body: "",
42
+ headers: { "WWW-Authenticate" => challenge_header },
43
+ request: req
44
+ )
45
+
46
+ feat.around_request(req) do |r|
47
+ call_count += 1
48
+ if call_count == 1
49
+ challenge_resp
50
+ else
51
+ retried_request = r
52
+ HTTP::Response.new(version: "1.1", status: 200, body: "", request: req)
53
+ end
54
+ end
55
+
56
+ retried_request
57
+ end
58
+
59
+ def challenge_header
60
+ 'Digest realm="testrealm", nonce="abc123", qop="auth", opaque="xyz789"'
61
+ end
62
+
63
+ def challenge_response
64
+ build_response(
65
+ status: 401,
66
+ headers: { "WWW-Authenticate" => challenge_header }
67
+ )
68
+ end
69
+
70
+ # -- #around_request: when response is not 401 --
71
+
72
+ def test_around_request_when_not_401_returns_response_unchanged
73
+ response = build_response(status: 200)
74
+ result = feature.around_request(request) { response }
75
+
76
+ assert_same response, result
77
+ end
78
+
79
+ # -- #around_request: when 401 without WWW-Authenticate --
80
+
81
+ def test_around_request_when_401_without_www_authenticate_returns_response_unchanged
82
+ response = build_response(status: 401)
83
+ result = feature.around_request(request) { response }
84
+
85
+ assert_same response, result
86
+ end
87
+
88
+ # -- #around_request: when 401 with Basic WWW-Authenticate --
89
+
90
+ def test_around_request_when_401_with_basic_returns_response_unchanged
91
+ response = build_response(
92
+ status: 401,
93
+ headers: { "WWW-Authenticate" => "Basic realm=\"test\"" }
94
+ )
95
+ result = feature.around_request(request) { response }
96
+
97
+ assert_same response, result
98
+ end
99
+
100
+ # -- #around_request: when 200 with Digest WWW-Authenticate --
101
+
102
+ def test_around_request_when_200_with_digest_returns_response_unchanged
103
+ response = build_response(
104
+ status: 200,
105
+ headers: { "WWW-Authenticate" => 'Digest realm="test", nonce="abc"' }
106
+ )
107
+
108
+ call_count = 0
109
+ result = feature.around_request(request) do |_req|
110
+ call_count += 1
111
+ response
112
+ end
113
+
114
+ assert_same response, result
115
+ assert_equal 1, call_count, "should not retry for non-401 responses"
116
+ end
117
+
118
+ # -- #around_request: when 401 with Digest challenge --
119
+
120
+ def test_around_request_with_digest_challenge_retries_with_digest_authorization
121
+ calls = []
122
+ feature.around_request(request) do |req|
123
+ calls << req
124
+ calls.length == 1 ? challenge_response : build_response(status: 200)
125
+ end
126
+
127
+ assert_equal 2, calls.length
128
+ assert_nil calls[0].headers["Authorization"]
129
+ assert_includes calls[1].headers["Authorization"], "Digest "
130
+ end
131
+
132
+ def test_around_request_with_digest_challenge_flushes_401_body_before_retrying
133
+ flushed = false
134
+ body_mock = Minitest::Mock.new
135
+ body_mock.expect(:to_s, "")
136
+
137
+ challenge_resp = HTTP::Response.new(
138
+ version: "1.1", status: 401,
139
+ headers: { "WWW-Authenticate" => challenge_header },
140
+ body: body_mock,
141
+ request: request
142
+ )
143
+
144
+ call_count = 0
145
+ feature.around_request(request) do |_req|
146
+ call_count += 1
147
+ if call_count == 1
148
+ challenge_resp
149
+ else
150
+ flushed = body_mock.verify
151
+ build_response(status: 200)
152
+ end
153
+ end
154
+
155
+ assert flushed, "response body should be flushed (read) before retry"
156
+ end
157
+
158
+ def test_around_request_with_digest_challenge_returns_the_retried_response
159
+ ok_response = build_response(status: 200)
160
+
161
+ call_count = 0
162
+ result = feature.around_request(request) do |_req|
163
+ call_count += 1
164
+ call_count == 1 ? challenge_response : ok_response
165
+ end
166
+
167
+ assert_same ok_response, result
168
+ end
169
+
170
+ def test_around_request_with_digest_challenge_preserves_original_request_headers
171
+ retried_request = nil
172
+ call_count = 0
173
+
174
+ feature.around_request(request) do |req|
175
+ call_count += 1
176
+ if call_count == 1
177
+ challenge_response
178
+ else
179
+ retried_request = req
180
+ build_response(status: 200)
181
+ end
182
+ end
183
+
184
+ assert_equal "text/html", retried_request.headers["Accept"]
185
+ end
186
+
187
+ def test_around_request_with_digest_challenge_preserves_original_request_verb
188
+ post_request = HTTP::Request.new(
189
+ verb: :post,
190
+ uri: "https://example.com/protected",
191
+ body: "data"
192
+ )
193
+
194
+ retried_request = nil
195
+ call_count = 0
196
+
197
+ feature.around_request(post_request) do |req|
198
+ call_count += 1
199
+ if call_count == 1
200
+ HTTP::Response.new(
201
+ version: "1.1", status: 401, body: "",
202
+ headers: { "WWW-Authenticate" => challenge_header },
203
+ request: post_request
204
+ )
205
+ else
206
+ retried_request = req
207
+ build_response(status: 200)
208
+ end
209
+ end
210
+
211
+ assert_equal :post, retried_request.verb
212
+ end
213
+
214
+ def test_around_request_with_digest_challenge_preserves_original_request_body
215
+ post_request = HTTP::Request.new(
216
+ verb: :post,
217
+ uri: "https://example.com/protected",
218
+ body: "request body data"
219
+ )
220
+
221
+ retried_request = nil
222
+ call_count = 0
223
+
224
+ feature.around_request(post_request) do |req|
225
+ call_count += 1
226
+ if call_count == 1
227
+ HTTP::Response.new(
228
+ version: "1.1", status: 401, body: "",
229
+ headers: { "WWW-Authenticate" => challenge_header },
230
+ request: post_request
231
+ )
232
+ else
233
+ retried_request = req
234
+ build_response(status: 200)
235
+ end
236
+ end
237
+
238
+ assert_equal "request body data", retried_request.body.source
239
+ end
240
+
241
+ def test_around_request_with_digest_challenge_preserves_original_request_version
242
+ versioned_request = HTTP::Request.new(
243
+ verb: :get,
244
+ uri: "https://example.com/protected",
245
+ version: "1.0"
246
+ )
247
+
248
+ retried_request = nil
249
+ call_count = 0
250
+
251
+ feature.around_request(versioned_request) do |req|
252
+ call_count += 1
253
+ if call_count == 1
254
+ HTTP::Response.new(
255
+ version: "1.0", status: 401, body: "",
256
+ headers: { "WWW-Authenticate" => challenge_header },
257
+ request: versioned_request
258
+ )
259
+ else
260
+ retried_request = req
261
+ build_response(status: 200)
262
+ end
263
+ end
264
+
265
+ assert_equal "1.0", retried_request.version
266
+ end
267
+
268
+ def test_around_request_with_digest_challenge_preserves_original_request_uri_normalizer
269
+ normalizer = ->(uri) { HTTP::URI::NORMALIZER.call(uri) }
270
+ custom_request = HTTP::Request.new(
271
+ verb: :get,
272
+ uri: "https://example.com/protected",
273
+ uri_normalizer: normalizer
274
+ )
275
+
276
+ retried_request = nil
277
+ call_count = 0
278
+
279
+ feature.around_request(custom_request) do |req|
280
+ call_count += 1
281
+ if call_count == 1
282
+ HTTP::Response.new(
283
+ version: "1.1", status: 401, body: "",
284
+ headers: { "WWW-Authenticate" => challenge_header },
285
+ request: custom_request
286
+ )
287
+ else
288
+ retried_request = req
289
+ build_response(status: 200)
290
+ end
291
+ end
292
+
293
+ assert_same normalizer, retried_request.uri_normalizer
294
+ end
295
+
296
+ def test_around_request_with_digest_challenge_preserves_original_request_proxy
297
+ proxy_hash = { proxy_address: "proxy.example.com", proxy_port: 8080 }
298
+ proxy_request = HTTP::Request.new(
299
+ verb: :get,
300
+ uri: "https://example.com/protected",
301
+ proxy: proxy_hash
302
+ )
303
+
304
+ retried_request = nil
305
+ call_count = 0
306
+
307
+ feature.around_request(proxy_request) do |req|
308
+ call_count += 1
309
+ if call_count == 1
310
+ HTTP::Response.new(
311
+ version: "1.1", status: 401, body: "",
312
+ headers: { "WWW-Authenticate" => challenge_header },
313
+ request: proxy_request
314
+ )
315
+ else
316
+ retried_request = req
317
+ build_response(status: 200)
318
+ end
319
+ end
320
+
321
+ assert_equal proxy_hash, retried_request.proxy
322
+ end
323
+
324
+ # -- digest computation --
325
+
326
+ def rfc_feature
327
+ @rfc_feature ||= HTTP::Features::DigestAuth.new(user: "Mufasa", pass: "Circle Of Life")
328
+ end
329
+
330
+ def rfc_challenge
331
+ 'Digest realm="testrealm@host.com", qop="auth", ' \
332
+ 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", ' \
333
+ 'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
334
+ end
335
+
336
+ def rfc_request
337
+ @rfc_request ||= HTTP::Request.new(verb: :get, uri: "http://www.nowhere.org/dir/index.html")
338
+ end
339
+
340
+ def rfc_response
341
+ HTTP::Response.new(
342
+ version: "1.1", status: 401, body: "",
343
+ headers: { "WWW-Authenticate" => rfc_challenge },
344
+ request: rfc_request
345
+ )
346
+ end
347
+
348
+ def test_digest_computation_produces_correct_ha1_for_md5
349
+ expected = "939e7578ed9e3c518a452acee763bce9"
350
+ ha1 = rfc_feature.send(:compute_ha1, "MD5", "testrealm@host.com",
351
+ "dcd98b7102dd2f0e8b11d0f600bfb0c093", "0a4f113b")
352
+
353
+ assert_equal expected, ha1
354
+ end
355
+
356
+ def test_digest_computation_produces_correct_ha1_for_md5_sess
357
+ base = Digest::MD5.hexdigest("Mufasa:testrealm@host.com:Circle Of Life")
358
+ expected = Digest::MD5.hexdigest("#{base}:servernonce:clientnonce")
359
+ ha1 = rfc_feature.send(:compute_ha1, "MD5-sess", "testrealm@host.com",
360
+ "servernonce", "clientnonce")
361
+
362
+ assert_equal expected, ha1
363
+ end
364
+
365
+ def test_digest_computation_produces_different_ha1_for_sess_vs_non_sess
366
+ ha1_md5 = rfc_feature.send(:compute_ha1, "MD5", "realm", "nonce", "cnonce")
367
+ ha1_sess = rfc_feature.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce")
368
+
369
+ refute_equal ha1_md5, ha1_sess
370
+ end
371
+
372
+ def test_digest_computation_produces_correct_ha2_for_md5
373
+ expected = "39aff3a2bab6126f332b942af96d3366"
374
+ ha2 = rfc_feature.send(:compute_ha2, "MD5", "GET", "/dir/index.html")
375
+
376
+ assert_equal expected, ha2
377
+ end
378
+
379
+ def test_digest_computation_produces_correct_response_with_qop_auth
380
+ ha1 = "939e7578ed9e3c518a452acee763bce9"
381
+ ha2 = "39aff3a2bab6126f332b942af96d3366"
382
+ expected = "6629fae49393a05397450978507c4ef1"
383
+
384
+ result = rfc_feature.send(:compute_response, "MD5", ha1, ha2,
385
+ nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093",
386
+ nonce_count: "00000001", cnonce: "0a4f113b", qop: "auth")
387
+
388
+ assert_equal expected, result
389
+ end
390
+
391
+ def test_digest_computation_includes_all_required_fields_in_authorization_header
392
+ retried_request = nil
393
+ call_count = 0
394
+
395
+ rfc_feature.around_request(rfc_request) do |req|
396
+ call_count += 1
397
+ if call_count == 1
398
+ rfc_response
399
+ else
400
+ retried_request = req
401
+ HTTP::Response.new(version: "1.1", status: 200, body: "", request: rfc_request)
402
+ end
403
+ end
404
+
405
+ auth = retried_request.headers["Authorization"]
406
+
407
+ assert_includes auth, 'username="Mufasa"'
408
+ assert_includes auth, 'realm="testrealm@host.com"'
409
+ assert_includes auth, 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"'
410
+ assert_includes auth, 'uri="/dir/index.html"'
411
+ assert_includes auth, "qop=auth,"
412
+ assert_includes auth, "nc=00000001"
413
+ assert_match(/cnonce="[0-9a-f]{32}"/, auth)
414
+ assert_match(/response="[0-9a-f]{32}"/, auth)
415
+ assert_includes auth, 'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
416
+ assert_includes auth, "algorithm=MD5"
417
+ end
418
+
419
+ def test_digest_computation_uses_correct_field_ordering_in_header
420
+ retried_request = nil
421
+ call_count = 0
422
+
423
+ rfc_feature.around_request(rfc_request) do |req|
424
+ call_count += 1
425
+ if call_count == 1
426
+ rfc_response
427
+ else
428
+ retried_request = req
429
+ HTTP::Response.new(version: "1.1", status: 200, body: "", request: rfc_request)
430
+ end
431
+ end
432
+
433
+ auth = retried_request.headers["Authorization"]
434
+
435
+ username_pos = auth.index("username=")
436
+ realm_pos = auth.index("realm=")
437
+ nonce_pos = auth.index("nonce=")
438
+ uri_pos = auth.index("uri=")
439
+ qop_pos = auth.index("qop=")
440
+ nc_pos = auth.index("nc=")
441
+ cnonce_pos = auth.index("cnonce=")
442
+ response_pos = auth.index("response=")
443
+ opaque_pos = auth.index("opaque=")
444
+ algo_pos = auth.index("algorithm=")
445
+
446
+ assert_operator username_pos, :<, realm_pos
447
+ assert_operator realm_pos, :<, nonce_pos
448
+ assert_operator nonce_pos, :<, uri_pos
449
+ assert_operator uri_pos, :<, qop_pos
450
+ assert_operator qop_pos, :<, nc_pos
451
+ assert_operator nc_pos, :<, cnonce_pos
452
+ assert_operator cnonce_pos, :<, response_pos
453
+ assert_operator response_pos, :<, opaque_pos
454
+ assert_operator opaque_pos, :<, algo_pos
455
+ end
456
+
457
+ def test_digest_computation_produces_deterministic_digest_with_fixed_cnonce
458
+ SecureRandom.stub(:hex, "0a4f113b00000000000000000a4f113b") do
459
+ retried_request = nil
460
+ call_count = 0
461
+
462
+ rfc_feature.around_request(rfc_request) do |req|
463
+ call_count += 1
464
+ if call_count == 1
465
+ rfc_response
466
+ else
467
+ retried_request = req
468
+ HTTP::Response.new(version: "1.1", status: 200, body: "", request: rfc_request)
469
+ end
470
+ end
471
+
472
+ auth = retried_request.headers["Authorization"]
473
+ ha1 = Digest::MD5.hexdigest("Mufasa:testrealm@host.com:Circle Of Life")
474
+ ha2 = Digest::MD5.hexdigest("GET:/dir/index.html")
475
+ expected_response = Digest::MD5.hexdigest(
476
+ "#{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b00000000000000000a4f113b:auth:#{ha2}"
477
+ )
478
+
479
+ assert_includes auth, %(response="#{expected_response}")
480
+ assert_includes auth, 'cnonce="0a4f113b00000000000000000a4f113b"'
481
+ end
482
+ end
483
+
484
+ # -- algorithm support --
485
+
486
+ def test_algorithm_support_sha256
487
+ challenge = 'Digest realm="test", nonce="abc", algorithm=SHA-256'
488
+
489
+ SecureRandom.stub(:hex, "fixed_cnonce_value_xx") do
490
+ retried = perform_digest_challenge(feature, request, challenge)
491
+ auth = retried.headers["Authorization"]
492
+
493
+ assert_includes auth, "algorithm=SHA-256"
494
+
495
+ ha1 = Digest::SHA256.hexdigest("admin:test:secret")
496
+ ha2 = Digest::SHA256.hexdigest("GET:/protected")
497
+ expected = Digest::SHA256.hexdigest("#{ha1}:abc:#{ha2}")
498
+
499
+ assert_includes auth, %(response="#{expected}")
500
+ end
501
+ end
502
+
503
+ def test_algorithm_support_md5_sess
504
+ challenge = 'Digest realm="test", nonce="abc", algorithm=MD5-sess, qop="auth"'
505
+
506
+ SecureRandom.stub(:hex, "fixedcnonce0000x") do
507
+ retried = perform_digest_challenge(feature, request, challenge)
508
+ auth = retried.headers["Authorization"]
509
+
510
+ assert_includes auth, "algorithm=MD5-sess"
511
+
512
+ base_ha1 = Digest::MD5.hexdigest("admin:test:secret")
513
+ ha1 = Digest::MD5.hexdigest("#{base_ha1}:abc:fixedcnonce0000x")
514
+ ha2 = Digest::MD5.hexdigest("GET:/protected")
515
+ expected = Digest::MD5.hexdigest("#{ha1}:abc:00000001:fixedcnonce0000x:auth:#{ha2}")
516
+
517
+ assert_includes auth, %(response="#{expected}")
518
+ end
519
+ end
520
+
521
+ def test_algorithm_support_sha256_sess
522
+ challenge = 'Digest realm="test", nonce="abc", algorithm=SHA-256-sess, qop="auth"'
523
+
524
+ SecureRandom.stub(:hex, "fixedcnonce0000x") do
525
+ retried = perform_digest_challenge(feature, request, challenge)
526
+ auth = retried.headers["Authorization"]
527
+
528
+ assert_includes auth, "algorithm=SHA-256-sess"
529
+
530
+ base_ha1 = Digest::SHA256.hexdigest("admin:test:secret")
531
+ ha1 = Digest::SHA256.hexdigest("#{base_ha1}:abc:fixedcnonce0000x")
532
+ ha2 = Digest::SHA256.hexdigest("GET:/protected")
533
+ expected = Digest::SHA256.hexdigest("#{ha1}:abc:00000001:fixedcnonce0000x:auth:#{ha2}")
534
+
535
+ assert_includes auth, %(response="#{expected}")
536
+ end
537
+ end
538
+
539
+ def test_algorithm_support_raises_for_unsupported_algorithm
540
+ challenge = 'Digest realm="test", nonce="abc", algorithm=UNSUPPORTED'
541
+ response = build_response(
542
+ status: 401,
543
+ headers: { "WWW-Authenticate" => challenge }
544
+ )
545
+
546
+ call_count = 0
547
+ assert_raises(KeyError) do
548
+ feature.around_request(request) do |_req|
549
+ call_count += 1
550
+ call_count == 1 ? response : build_response(status: 200)
551
+ end
552
+ end
553
+ end
554
+
555
+ def test_algorithm_support_defaults_to_md5_when_algorithm_not_specified
556
+ challenge = 'Digest realm="test", nonce="abc"'
557
+
558
+ SecureRandom.stub(:hex, "fixedcnonce0000x") do
559
+ retried = perform_digest_challenge(feature, request, challenge)
560
+ auth = retried.headers["Authorization"]
561
+
562
+ assert_includes auth, "algorithm=MD5"
563
+
564
+ ha1 = Digest::MD5.hexdigest("admin:test:secret")
565
+ ha2 = Digest::MD5.hexdigest("GET:/protected")
566
+ expected = Digest::MD5.hexdigest("#{ha1}:abc:#{ha2}")
567
+
568
+ assert_includes auth, %(response="#{expected}")
569
+ end
570
+ end
571
+
572
+ # -- qop handling --
573
+
574
+ def test_qop_selects_auth_when_present_among_multiple_values
575
+ result = feature.send(:select_qop, "auth-int,auth")
576
+
577
+ assert_equal "auth", result
578
+ end
579
+
580
+ def test_qop_returns_first_value_when_auth_not_available
581
+ result = feature.send(:select_qop, "auth-int,other")
582
+
583
+ assert_equal "auth-int", result
584
+ end
585
+
586
+ def test_qop_returns_nil_when_qop_str_is_nil
587
+ result = feature.send(:select_qop, nil)
588
+
589
+ assert_nil result
590
+ end
591
+
592
+ def test_qop_handles_spaces_around_commas
593
+ result = feature.send(:select_qop, "auth-int , auth")
594
+
595
+ assert_equal "auth", result
596
+ end
597
+
598
+ def test_qop_handles_leading_space_after_comma
599
+ result = feature.send(:select_qop, "auth-int, auth")
600
+
601
+ assert_equal "auth", result
602
+ end
603
+
604
+ def test_qop_handles_no_spaces_around_comma
605
+ result = feature.send(:select_qop, "other,auth")
606
+
607
+ assert_equal "auth", result
608
+ end
609
+
610
+ def test_qop_returns_single_value_as_is
611
+ result = feature.send(:select_qop, "auth")
612
+
613
+ assert_equal "auth", result
614
+ end
615
+
616
+ def test_qop_strips_trailing_whitespace_from_first_when_auth_not_available
617
+ result = feature.send(:select_qop, "auth-int ,other")
618
+
619
+ assert_equal "auth-int", result
620
+ end
621
+
622
+ def test_qop_prefers_auth_when_multiple_values_offered_in_header
623
+ challenge = 'Digest realm="test", nonce="abc", qop="auth-int,auth"'
624
+ retried = perform_digest_challenge(feature, request, challenge)
625
+ auth = retried.headers["Authorization"]
626
+
627
+ assert_match(/qop=auth,/, auth)
628
+ end
629
+
630
+ def test_qop_uses_first_value_when_auth_not_available_in_header
631
+ challenge = 'Digest realm="test", nonce="abc", qop="auth-int"'
632
+ retried = perform_digest_challenge(feature, request, challenge)
633
+
634
+ assert_match(/qop=auth-int,/, retried.headers["Authorization"])
635
+ end
636
+
637
+ def test_qop_omits_fields_when_server_does_not_specify_qop
638
+ challenge = 'Digest realm="test", nonce="abc"'
639
+ retried = perform_digest_challenge(feature, request, challenge)
640
+ auth = retried.headers["Authorization"]
641
+
642
+ refute_includes auth, "qop="
643
+ refute_includes auth, "nc="
644
+ refute_includes auth, "cnonce="
645
+ end
646
+
647
+ def test_qop_computes_response_without_qop_correctly
648
+ ha1 = "ha1value"
649
+ ha2 = "ha2value"
650
+ expected = Digest::MD5.hexdigest("ha1value:testnonce:ha2value")
651
+ result = feature.send(:compute_response, "MD5", ha1, ha2,
652
+ nonce: "testnonce", nonce_count: "00000001",
653
+ cnonce: "cnonce", qop: nil)
654
+
655
+ assert_equal expected, result
656
+ end
657
+
658
+ def test_qop_computes_response_with_qop_correctly_using_all_components
659
+ ha1 = "ha1hex"
660
+ ha2 = "ha2hex"
661
+ expected = Digest::MD5.hexdigest("ha1hex:nonce1:00000001:cnonce1:auth:ha2hex")
662
+ result = feature.send(:compute_response, "MD5", ha1, ha2,
663
+ nonce: "nonce1", nonce_count: "00000001",
664
+ cnonce: "cnonce1", qop: "auth")
665
+
666
+ assert_equal expected, result
667
+ end
668
+
669
+ # -- opaque handling --
670
+
671
+ def test_opaque_omits_when_not_in_challenge
672
+ challenge = 'Digest realm="test", nonce="abc", qop="auth"'
673
+ retried = perform_digest_challenge(feature, request, challenge)
674
+
675
+ refute_includes retried.headers["Authorization"], "opaque="
676
+ end
677
+
678
+ def test_opaque_includes_when_present_in_challenge
679
+ challenge = 'Digest realm="test", nonce="abc", qop="auth", opaque="opq123"'
680
+ retried = perform_digest_challenge(feature, request, challenge)
681
+
682
+ assert_includes retried.headers["Authorization"], 'opaque="opq123"'
683
+ end
684
+
685
+ # -- challenge parsing --
686
+
687
+ def test_challenge_parsing_parses_quoted_values
688
+ header = 'Digest realm="test realm", nonce="abc123"'
689
+ result = feature.send(:parse_challenge, header)
690
+
691
+ assert_equal "test realm", result["realm"]
692
+ assert_equal "abc123", result["nonce"]
693
+ end
694
+
695
+ def test_challenge_parsing_parses_unquoted_values
696
+ header = 'Digest realm="test", algorithm=SHA-256'
697
+ result = feature.send(:parse_challenge, header)
698
+
699
+ assert_equal "SHA-256", result["algorithm"]
700
+ end
701
+
702
+ def test_challenge_parsing_parses_mixed_quoted_and_unquoted_values
703
+ header = 'Digest realm="test", nonce="n1", qop="auth", algorithm=MD5, opaque="op1"'
704
+ result = feature.send(:parse_challenge, header)
705
+
706
+ assert_equal "test", result["realm"]
707
+ assert_equal "n1", result["nonce"]
708
+ assert_equal "auth", result["qop"]
709
+ assert_equal "MD5", result["algorithm"]
710
+ assert_equal "op1", result["opaque"]
711
+ end
712
+
713
+ def test_challenge_parsing_handles_empty_quoted_values
714
+ header = 'Digest realm="", nonce="abc"'
715
+ result = feature.send(:parse_challenge, header)
716
+
717
+ assert_equal "", result["realm"]
718
+ assert_equal "abc", result["nonce"]
719
+ end
720
+
721
+ def test_challenge_parsing_ignores_digest_scheme_prefix
722
+ header = 'Digest realm="test", nonce="abc"'
723
+ result = feature.send(:parse_challenge, header)
724
+
725
+ assert_nil result["Digest"]
726
+ assert_equal 2, result.size
727
+ end
728
+
729
+ def test_challenge_parsing_handles_values_containing_percent_characters
730
+ header = 'Digest realm="test%20realm", nonce="abc"'
731
+ result = feature.send(:parse_challenge, header)
732
+
733
+ assert_equal "test%20realm", result["realm"]
734
+ end
735
+
736
+ # -- #hex_digest --
737
+
738
+ def test_hex_digest_uses_md5_for_md5_algorithm
739
+ expected = Digest::MD5.hexdigest("test_data")
740
+ result = feature.send(:hex_digest, "MD5", "test_data")
741
+
742
+ assert_equal expected, result
743
+ end
744
+
745
+ def test_hex_digest_uses_sha256_for_sha256_algorithm
746
+ expected = Digest::SHA256.hexdigest("test_data")
747
+ result = feature.send(:hex_digest, "SHA-256", "test_data")
748
+
749
+ assert_equal expected, result
750
+ end
751
+
752
+ def test_hex_digest_strips_sess_suffix_for_algorithm_lookup
753
+ md5_result = feature.send(:hex_digest, "MD5-sess", "test_data")
754
+ expected = Digest::MD5.hexdigest("test_data")
755
+
756
+ assert_equal expected, md5_result
757
+ end
758
+
759
+ def test_hex_digest_strips_sess_suffix_case_insensitively
760
+ result = feature.send(:hex_digest, "MD5-SESS", "test_data")
761
+ expected = Digest::MD5.hexdigest("test_data")
762
+
763
+ assert_equal expected, result
764
+ end
765
+
766
+ def test_hex_digest_does_not_match_partial_sess_in_algorithm_name
767
+ assert_raises(KeyError) do
768
+ feature.send(:hex_digest, "-sessMD5", "test_data")
769
+ end
770
+ end
771
+
772
+ # -- #compute_ha1 --
773
+
774
+ def test_compute_ha1_returns_base_ha1_for_non_sess_algorithms
775
+ expected = Digest::MD5.hexdigest("admin:realm:secret")
776
+ result = feature.send(:compute_ha1, "MD5", "realm", "nonce", "cnonce")
777
+
778
+ assert_equal expected, result
779
+ end
780
+
781
+ def test_compute_ha1_computes_session_ha1_for_sess_algorithms
782
+ base = Digest::MD5.hexdigest("admin:realm:secret")
783
+ expected = Digest::MD5.hexdigest("#{base}:servernonce:clientnonce")
784
+ result = feature.send(:compute_ha1, "MD5-sess", "realm", "servernonce", "clientnonce")
785
+
786
+ assert_equal expected, result
787
+ end
788
+
789
+ def test_compute_ha1_uses_nonce_in_session_ha1_computation
790
+ result1 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce1", "cnonce")
791
+ result2 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce2", "cnonce")
792
+
793
+ refute_equal result1, result2
794
+ end
795
+
796
+ def test_compute_ha1_uses_cnonce_in_session_ha1_computation
797
+ result1 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce1")
798
+ result2 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce2")
799
+
800
+ refute_equal result1, result2
801
+ end
802
+
803
+ def test_compute_ha1_uses_base_ha1_in_session_ha1_computation
804
+ feat1 = HTTP::Features::DigestAuth.new(user: "user1", pass: "pass1")
805
+ feat2 = HTTP::Features::DigestAuth.new(user: "user2", pass: "pass2")
806
+ result1 = feat1.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce")
807
+ result2 = feat2.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce")
808
+
809
+ refute_equal result1, result2
810
+ end
811
+
812
+ def test_compute_ha1_computes_sha256_sess_correctly
813
+ base = Digest::SHA256.hexdigest("admin:realm:secret")
814
+ expected = Digest::SHA256.hexdigest("#{base}:nonce:cnonce")
815
+ result = feature.send(:compute_ha1, "SHA-256-sess", "realm", "nonce", "cnonce")
816
+
817
+ assert_equal expected, result
818
+ end
819
+
820
+ # -- #compute_auth_header --
821
+
822
+ def test_compute_auth_header_passes_correct_ha1_and_ha2_to_compute_response
823
+ ha1 = "correctha1"
824
+ ha2 = "correctha2"
825
+ challenge = { "realm" => "test" }
826
+ result = feature.send(:compute_auth_header,
827
+ algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "cnonce",
828
+ nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
829
+ expected_response = Digest::MD5.hexdigest("correctha1:nonce:00000001:cnonce:auth:correctha2")
830
+
831
+ assert_includes result, %(response="#{expected_response}")
832
+ end
833
+
834
+ def test_compute_auth_header_passes_nonce_to_compute_response
835
+ ha1 = "ha1val"
836
+ ha2 = "ha2val"
837
+ challenge = { "realm" => "test" }
838
+ result = feature.send(:compute_auth_header,
839
+ algorithm: "MD5", qop: "auth", nonce: "testnonce", cnonce: "cnonce",
840
+ nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
841
+ expected_response = Digest::MD5.hexdigest("ha1val:testnonce:00000001:cnonce:auth:ha2val")
842
+
843
+ assert_includes result, %(response="#{expected_response}")
844
+ end
845
+
846
+ def test_compute_auth_header_passes_cnonce_to_compute_response
847
+ ha1 = "ha1val"
848
+ ha2 = "ha2val"
849
+ challenge = { "realm" => "test" }
850
+ result = feature.send(:compute_auth_header,
851
+ algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "testcnonce",
852
+ nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
853
+ expected_response = Digest::MD5.hexdigest("ha1val:nonce:00000001:testcnonce:auth:ha2val")
854
+
855
+ assert_includes result, %(response="#{expected_response}")
856
+ end
857
+
858
+ def test_compute_auth_header_passes_nonce_count_to_compute_response
859
+ ha1 = "ha1val"
860
+ ha2 = "ha2val"
861
+ challenge = { "realm" => "test" }
862
+ result = feature.send(:compute_auth_header,
863
+ algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "cnonce",
864
+ nonce_count: "00000002", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
865
+ expected_response = Digest::MD5.hexdigest("ha1val:nonce:00000002:cnonce:auth:ha2val")
866
+
867
+ assert_includes result, %(response="#{expected_response}")
868
+ end
869
+
870
+ def test_compute_auth_header_passes_qop_to_compute_response
871
+ ha1 = "ha1val"
872
+ ha2 = "ha2val"
873
+ challenge = { "realm" => "test" }
874
+
875
+ result_auth = feature.send(:compute_auth_header,
876
+ algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "cnonce",
877
+ nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
878
+
879
+ result_nil = feature.send(:compute_auth_header,
880
+ algorithm: "MD5", qop: nil, nonce: "nonce", cnonce: "cnonce",
881
+ nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
882
+
883
+ refute_equal result_auth, result_nil
884
+ end
885
+
886
+ # -- #build_auth integration --
887
+
888
+ def test_build_auth_uses_select_qop_to_process_qop_from_challenge
889
+ challenge = 'Digest realm="test", nonce="abc", qop="auth-int,auth"'
890
+ retried = perform_digest_challenge(feature, request, challenge)
891
+ auth = retried.headers["Authorization"]
892
+
893
+ refute_includes auth, "auth-int,auth"
894
+ assert_match(/qop=auth,/, auth)
895
+ end
896
+
897
+ def test_build_auth_generates_cnonce_of_correct_length
898
+ retried = perform_digest_challenge(feature, request,
899
+ 'Digest realm="test", nonce="abc", qop="auth"')
900
+ auth = retried.headers["Authorization"]
901
+
902
+ assert_match(/cnonce="[0-9a-f]{32}"/, auth)
903
+ cnonce = auth[/cnonce="([0-9a-f]+)"/, 1]
904
+
905
+ assert_equal 32, cnonce.length
906
+ end
907
+
908
+ def test_build_auth_includes_uri_from_request_in_header
909
+ retried = perform_digest_challenge(feature, request,
910
+ 'Digest realm="test", nonce="abc"')
911
+ auth = retried.headers["Authorization"]
912
+
913
+ assert_includes auth, 'uri="/protected"'
914
+ end
915
+
916
+ def test_build_auth_uses_request_uri_in_digest_computation
917
+ req1 = HTTP::Request.new(verb: :get, uri: "https://example.com/path1")
918
+ req2 = HTTP::Request.new(verb: :get, uri: "https://example.com/path2")
919
+ challenge = 'Digest realm="test", nonce="abc"'
920
+
921
+ SecureRandom.stub(:hex, "fixedcnonce0000x") do
922
+ retried1 = perform_digest_challenge(feature, req1, challenge)
923
+ retried2 = perform_digest_challenge(feature, req2, challenge)
924
+
925
+ resp1 = retried1.headers["Authorization"][/response="([^"]+)"/, 1]
926
+ resp2 = retried2.headers["Authorization"][/response="([^"]+)"/, 1]
927
+
928
+ refute_equal resp1, resp2
929
+ end
930
+ end
931
+
932
+ def test_build_auth_uses_verb_in_digest_computation
933
+ get_req = HTTP::Request.new(verb: :get, uri: "https://example.com/protected")
934
+ post_req = HTTP::Request.new(verb: :post, uri: "https://example.com/protected", body: "data")
935
+ challenge = 'Digest realm="test", nonce="abc"'
936
+
937
+ SecureRandom.stub(:hex, "fixedcnonce0000x") do
938
+ retried_get = perform_digest_challenge(feature, get_req, challenge)
939
+ retried_post = perform_digest_challenge(feature, post_req, challenge)
940
+
941
+ resp_get = retried_get.headers["Authorization"][/response="([^"]+)"/, 1]
942
+ resp_post = retried_post.headers["Authorization"][/response="([^"]+)"/, 1]
943
+
944
+ refute_equal resp_get, resp_post
945
+ end
946
+ end
947
+
948
+ # -- #build_header --
949
+
950
+ def test_build_header_formats_header_with_qop_fields_in_correct_order
951
+ result = feature.send(:build_header,
952
+ username: "user", realm: "realm", nonce: "nonce",
953
+ uri: "/path", qop: "auth", nonce_count: "00000001",
954
+ cnonce: "cn", response: "resp", opaque: "op",
955
+ algorithm: "MD5")
956
+
957
+ expected = 'Digest username="user", realm="realm", nonce="nonce", uri="/path", ' \
958
+ 'qop=auth, nc=00000001, cnonce="cn", response="resp", opaque="op", algorithm=MD5'
959
+
960
+ assert_equal expected, result
961
+ end
962
+
963
+ def test_build_header_formats_header_without_qop_fields_when_qop_is_nil
964
+ result = feature.send(:build_header,
965
+ username: "user", realm: "realm", nonce: "nonce",
966
+ uri: "/path", qop: nil, nonce_count: "00000001",
967
+ cnonce: "cn", response: "resp", opaque: nil,
968
+ algorithm: "MD5")
969
+
970
+ expected = 'Digest username="user", realm="realm", nonce="nonce", uri="/path", ' \
971
+ 'response="resp", algorithm=MD5'
972
+
973
+ assert_equal expected, result
974
+ end
975
+
976
+ def test_build_header_formats_header_without_opaque_when_opaque_is_nil
977
+ result = feature.send(:build_header,
978
+ username: "user", realm: "realm", nonce: "nonce",
979
+ uri: "/path", qop: "auth", nonce_count: "00000001",
980
+ cnonce: "cn", response: "resp", opaque: nil,
981
+ algorithm: "MD5")
982
+
983
+ refute_includes result, "opaque="
984
+ assert_includes result, "algorithm=MD5"
985
+ end
986
+
987
+ # -- feature registration --
988
+
989
+ def test_feature_registration_is_registered_as_digest_auth
990
+ assert_equal HTTP::Features::DigestAuth, HTTP::Options.available_features[:digest_auth]
991
+ end
992
+
993
+ def test_feature_registration_is_a_feature
994
+ assert_kind_of HTTP::Feature, feature
995
+ end
996
+ end