rack-test 1.1.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/rack/test.rb CHANGED
@@ -1,23 +1,52 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'uri'
2
- require 'rack'
3
- require 'rack/mock_session'
4
- require 'rack/test/cookie_jar'
5
- require 'rack/test/mock_digest_request'
6
- require 'rack/test/utils'
7
- require 'rack/test/methods'
8
- require 'rack/test/uploaded_file'
9
- require 'rack/test/version'
4
+
5
+ # :nocov:
6
+ begin
7
+ require "rack/version"
8
+ rescue LoadError
9
+ require "rack"
10
+ else
11
+ if Rack.release >= '2.3'
12
+ require "rack/request"
13
+ require "rack/mock"
14
+ require "rack/utils"
15
+ else
16
+ require "rack"
17
+ end
18
+ end
19
+ # :nocov:
20
+
21
+ require 'forwardable'
22
+
23
+ require_relative 'test/cookie_jar'
24
+ require_relative 'test/utils'
25
+ require_relative 'test/methods'
26
+ require_relative 'test/uploaded_file'
27
+ require_relative 'test/version'
10
28
 
11
29
  module Rack
12
30
  module Test
31
+ # The default host to use for requests, when a full URI is not
32
+ # provided.
13
33
  DEFAULT_HOST = 'example.org'.freeze
34
+
35
+ # The default multipart boundary to use for multipart request bodies
14
36
  MULTIPART_BOUNDARY = '----------XnJLe9ZIbbGUYtzPQJ16u1'.freeze
15
37
 
38
+ # The starting boundary in multipart requests
39
+ START_BOUNDARY = "--#{MULTIPART_BOUNDARY}\r\n".freeze
40
+
41
+ # The ending boundary in multipart requests
42
+ END_BOUNDARY = "--#{MULTIPART_BOUNDARY}--\r\n".freeze
43
+
16
44
  # The common base class for exceptions raised by Rack::Test
17
45
  class Error < StandardError; end
18
46
 
19
- # This class represents a series of requests issued to a Rack app, sharing
20
- # a single cookie jar
47
+ # Rack::Test::Session handles a series of requests issued to a Rack app.
48
+ # It keeps track of the cookies for the session, and allows for setting headers
49
+ # and a default rack environment that is used for future requests.
21
50
  #
22
51
  # Rack::Test::Session's methods are most often called through Rack::Test::Methods,
23
52
  # which will automatically build a session when it's first used.
@@ -25,93 +54,98 @@ module Rack
25
54
  extend Forwardable
26
55
  include Rack::Test::Utils
27
56
 
28
- def_delegators :@rack_mock_session, :clear_cookies, :set_cookie, :last_response, :last_request
57
+ def self.new(app, default_host = DEFAULT_HOST) # :nodoc:
58
+ if app.is_a?(self)
59
+ # Backwards compatibility for initializing with Rack::MockSession
60
+ app
61
+ else
62
+ super
63
+ end
64
+ end
29
65
 
30
- # Creates a Rack::Test::Session for a given Rack app or Rack::MockSession.
66
+ # The Rack::Test::CookieJar for the cookies for the current session.
67
+ attr_accessor :cookie_jar
68
+
69
+ # The default host used for the session for when using paths for URIs.
70
+ attr_reader :default_host
71
+
72
+ # Creates a Rack::Test::Session for a given Rack app or Rack::Test::BasicSession.
31
73
  #
32
74
  # Note: Generally, you won't need to initialize a Rack::Test::Session directly.
33
75
  # Instead, you should include Rack::Test::Methods into your testing context.
34
76
  # (See README.rdoc for an example)
35
- def initialize(mock_session)
36
- @headers = {}
37
- @env = {}
38
- @digest_username = nil
39
- @digest_password = nil
40
-
41
- @rack_mock_session = if mock_session.is_a?(MockSession)
42
- mock_session
43
- else
44
- MockSession.new(mock_session)
45
- end
46
-
47
- @default_host = @rack_mock_session.default_host
48
- end
49
-
50
- # Issue a GET request for the given URI with the given params and Rack
51
- # environment. Stores the issues request object in #last_request and
52
- # the app's response in #last_response. Yield #last_response to a block
53
- # if given.
54
77
  #
55
- # Example:
56
- # get "/"
57
- def get(uri, params = {}, env = {}, &block)
58
- custom_request('GET', uri, params, env, &block)
78
+ # The following methods are defined via metaprogramming: get, post, put, patch,
79
+ # delete, options, and head. Each method submits a request with the given request
80
+ # method, with the given URI and optional parameters and rack environment.
81
+ # Examples:
82
+ #
83
+ # # URI only:
84
+ # get("/") # GET /
85
+ # get("/?foo=bar") # GET /?foo=bar
86
+ #
87
+ # # URI and parameters
88
+ # get("/foo", 'bar'=>'baz') # GET /foo?bar=baz
89
+ # post("/foo", 'bar'=>'baz') # POST /foo (bar=baz in request body)
90
+ #
91
+ # # URI, parameters, and rack environment
92
+ # get("/bar", {}, 'CONTENT_TYPE'=>'foo')
93
+ # get("/bar", {'foo'=>'baz'}, 'HTTP_ACCEPT'=>'*')
94
+ #
95
+ # The above methods as well as #request and #custom_request store the Rack::Request
96
+ # submitted in #last_request. The methods store a Rack::MockResponse based on the
97
+ # response in #last_response. #last_response is also returned by the methods.
98
+ # If a block is given, #last_response is also yielded to the block.
99
+ def initialize(app, default_host = DEFAULT_HOST)
100
+ @env = {}
101
+ @app = app
102
+ @after_request = []
103
+ @default_host = default_host
104
+ @last_request = nil
105
+ @last_response = nil
106
+ clear_cookies
59
107
  end
60
108
 
61
- # Issue a POST request for the given URI. See #get
62
- #
63
- # Example:
64
- # post "/signup", "name" => "Bryan"
65
- def post(uri, params = {}, env = {}, &block)
66
- custom_request('POST', uri, params, env, &block)
109
+ %w[get post put patch delete options head].each do |method_name|
110
+ class_eval(<<-END, __FILE__, __LINE__+1)
111
+ def #{method_name}(uri, params = {}, env = {}, &block)
112
+ custom_request('#{method_name.upcase}', uri, params, env, &block)
113
+ end
114
+ END
67
115
  end
68
116
 
69
- # Issue a PUT request for the given URI. See #get
70
- #
71
- # Example:
72
- # put "/"
73
- def put(uri, params = {}, env = {}, &block)
74
- custom_request('PUT', uri, params, env, &block)
117
+ # Run a block after the each request completes.
118
+ def after_request(&block)
119
+ @after_request << block
75
120
  end
76
121
 
77
- # Issue a PATCH request for the given URI. See #get
78
- #
79
- # Example:
80
- # patch "/"
81
- def patch(uri, params = {}, env = {}, &block)
82
- custom_request('PATCH', uri, params, env, &block)
122
+ # Replace the current cookie jar with an empty cookie jar.
123
+ def clear_cookies
124
+ @cookie_jar = CookieJar.new([], @default_host)
83
125
  end
84
126
 
85
- # Issue a DELETE request for the given URI. See #get
86
- #
87
- # Example:
88
- # delete "/"
89
- def delete(uri, params = {}, env = {}, &block)
90
- custom_request('DELETE', uri, params, env, &block)
127
+ # Set a cookie in the current cookie jar.
128
+ def set_cookie(cookie, uri = nil)
129
+ cookie_jar.merge(cookie, uri)
91
130
  end
92
131
 
93
- # Issue an OPTIONS request for the given URI. See #get
94
- #
95
- # Example:
96
- # options "/"
97
- def options(uri, params = {}, env = {}, &block)
98
- custom_request('OPTIONS', uri, params, env, &block)
132
+ # Return the last request issued in the session. Raises an error if no
133
+ # requests have been sent yet.
134
+ def last_request
135
+ raise Error, 'No request yet. Request a page first.' unless @last_request
136
+ @last_request
99
137
  end
100
138
 
101
- # Issue a HEAD request for the given URI. See #get
102
- #
103
- # Example:
104
- # head "/"
105
- def head(uri, params = {}, env = {}, &block)
106
- custom_request('HEAD', uri, params, env, &block)
139
+ # Return the last response received in the session. Raises an error if
140
+ # no requests have been sent yet.
141
+ def last_response
142
+ raise Error, 'No response yet. Request a page first.' unless @last_response
143
+ @last_response
107
144
  end
108
145
 
109
146
  # Issue a request to the Rack app for the given URI and optional Rack
110
- # environment. Stores the issues request object in #last_request and
111
- # the app's response in #last_response. Yield #last_response to a block
112
- # if given.
147
+ # environment. Example:
113
148
  #
114
- # Example:
115
149
  # request "/"
116
150
  def request(uri, env = {}, &block)
117
151
  uri = parse_uri(uri, env)
@@ -119,9 +153,9 @@ module Rack
119
153
  process_request(uri, env, &block)
120
154
  end
121
155
 
122
- # Issue a request using the given verb for the given URI. See #get
156
+ # Issue a request using the given HTTP verb for the given URI, with optional
157
+ # params and rack environment. Example:
123
158
  #
124
- # Example:
125
159
  # custom_request "LINK", "/"
126
160
  def custom_request(verb, uri, params = {}, env = {}, &block)
127
161
  uri = parse_uri(uri, env)
@@ -133,22 +167,20 @@ module Rack
133
167
  # session. Use a value of nil to remove a previously configured header.
134
168
  #
135
169
  # In accordance with the Rack spec, headers will be included in the Rack
136
- # environment hash in HTTP_USER_AGENT form.
170
+ # environment hash in HTTP_USER_AGENT form. Example:
137
171
  #
138
- # Example:
139
- # header "User-Agent", "Firefox"
172
+ # header "user-agent", "Firefox"
140
173
  def header(name, value)
141
- if value.nil?
142
- @headers.delete(name)
143
- else
144
- @headers[name] = value
145
- end
174
+ name = name.upcase
175
+ name.tr!('-', '_')
176
+ name = "HTTP_#{name}" unless name == 'CONTENT_TYPE' || name == 'CONTENT_LENGTH'
177
+ env(name, value)
146
178
  end
147
179
 
148
- # Set an env var to be included on all subsequent requests through the
149
- # session. Use a value of nil to remove a previously configured env.
180
+ # Set an entry in the rack environment to be included on all subsequent
181
+ # requests through the session. Use a value of nil to remove a previously
182
+ # value. Example:
150
183
  #
151
- # Example:
152
184
  # env "rack.session", {:csrf => 'token'}
153
185
  def env(name, value)
154
186
  if value.nil?
@@ -170,16 +202,6 @@ module Rack
170
202
 
171
203
  alias authorize basic_authorize
172
204
 
173
- # Set the username and password for HTTP Digest authorization, to be
174
- # included in subsequent requests in the HTTP_AUTHORIZATION header.
175
- #
176
- # Example:
177
- # digest_authorize "bryan", "secret"
178
- def digest_authorize(username, password)
179
- @digest_username = username
180
- @digest_password = password
181
- end
182
-
183
205
  # Rack::Test will not follow any redirects automatically. This method
184
206
  # will follow the redirect returned (including setting the Referer header
185
207
  # on the new request) in the last response. If the last response was not
@@ -188,147 +210,173 @@ module Rack
188
210
  unless last_response.redirect?
189
211
  raise Error, 'Last response was not a redirect. Cannot follow_redirect!'
190
212
  end
191
- request_method, params =
192
- if last_response.status == 307
193
- [last_request.request_method.downcase.to_sym, last_request.params]
194
- else
195
- [:get, {}]
196
- end
213
+
214
+ if last_response.status == 307
215
+ request_method = last_request.request_method
216
+ params = last_request.params
217
+ else
218
+ request_method = 'GET'
219
+ params = {}
220
+ end
197
221
 
198
222
  # Compute the next location by appending the location header with the
199
223
  # last request, as per https://tools.ietf.org/html/rfc7231#section-7.1.2
200
224
  # Adding two absolute locations returns the right-hand location
201
225
  next_location = URI.parse(last_request.url) + URI.parse(last_response['Location'])
202
226
 
203
- send(
204
- request_method, next_location.to_s, params,
227
+ custom_request(
228
+ request_method,
229
+ next_location.to_s,
230
+ params,
205
231
  'HTTP_REFERER' => last_request.url,
206
232
  'rack.session' => last_request.session,
207
233
  'rack.session.options' => last_request.session_options
208
234
  )
209
235
  end
210
236
 
237
+ # Yield to the block, and restore the last request, last response, and
238
+ # cookie jar to the state they were prior to block execution upon
239
+ # exiting the block.
240
+ def restore_state
241
+ request = @last_request
242
+ response = @last_response
243
+ cookie_jar = @cookie_jar.dup
244
+ after_request = @after_request.dup
245
+
246
+ begin
247
+ yield
248
+ ensure
249
+ @last_request = request
250
+ @last_response = response
251
+ @cookie_jar = cookie_jar
252
+ @after_request = after_request
253
+ end
254
+ end
255
+
211
256
  private
212
257
 
213
- def parse_uri(path, env)
214
- URI.parse(path).tap do |uri|
215
- uri.path = "/#{uri.path}" unless uri.path[0] == '/'
216
- uri.host ||= @default_host
217
- uri.scheme ||= 'https' if env['HTTPS'] == 'on'
258
+ # :nocov:
259
+ if !defined?(Rack::RELEASE) || Gem::Version.new(Rack::RELEASE) < Gem::Version.new('2.2.2')
260
+ def close_body(body)
261
+ body.close if body.respond_to?(:close)
262
+ end
263
+ # :nocov:
264
+ else
265
+ # close() gets called automatically in newer Rack versions.
266
+ def close_body(body)
218
267
  end
219
268
  end
220
269
 
270
+ # Normalize URI based on given URI/path and environment.
271
+ def parse_uri(path, env)
272
+ uri = URI.parse(path)
273
+ uri.path = "/#{uri.path}" unless uri.path.start_with?('/')
274
+ uri.host ||= @default_host
275
+ uri.scheme ||= 'https' if env['HTTPS'] == 'on'
276
+ uri
277
+ end
278
+
279
+ DEFAULT_ENV = {
280
+ 'rack.test' => true,
281
+ 'REMOTE_ADDR' => '127.0.0.1',
282
+ 'SERVER_PROTOCOL' => 'HTTP/1.0',
283
+ }
284
+ # :nocov:
285
+ unless Rack.release >= '2.3'
286
+ DEFAULT_ENV['HTTP_VERSION'] = DEFAULT_ENV['SERVER_PROTOCOL']
287
+ end
288
+ # :nocov:
289
+ DEFAULT_ENV.freeze
290
+ private_constant :DEFAULT_ENV
291
+
292
+ # Update environment to use based on given URI.
221
293
  def env_for(uri, env)
222
- env = default_env.merge(env)
294
+ env = DEFAULT_ENV.merge(@env).merge!(env)
223
295
 
224
296
  env['HTTP_HOST'] ||= [uri.host, (uri.port if uri.port != uri.default_port)].compact.join(':')
225
-
226
- env.update('HTTPS' => 'on') if URI::HTTPS === uri
297
+ env['HTTPS'] = 'on' if URI::HTTPS === uri
227
298
  env['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' if env[:xhr]
228
-
229
- # TODO: Remove this after Rack 1.1 has been released.
230
- # Stringifying and upcasing methods has be commit upstream
231
299
  env['REQUEST_METHOD'] ||= env[:method] ? env[:method].to_s.upcase : 'GET'
232
300
 
233
- params = env.delete(:params) do {} end
301
+ params = env.delete(:params)
302
+ query_array = [uri.query]
234
303
 
235
304
  if env['REQUEST_METHOD'] == 'GET'
236
- # merge :params with the query string
305
+ # Treat params as query params
237
306
  if params
238
- params = parse_nested_query(params) if params.is_a?(String)
239
-
240
- uri.query = [uri.query, build_nested_query(params)].compact.reject { |v| v == '' }.join('&')
307
+ append_query_params(query_array, params)
241
308
  end
242
309
  elsif !env.key?(:input)
243
310
  env['CONTENT_TYPE'] ||= 'application/x-www-form-urlencoded'
311
+ params ||= {}
312
+ multipart = env.has_key?(:multipart) ? env.delete(:multipart) : env['CONTENT_TYPE'].start_with?('multipart/')
244
313
 
245
314
  if params.is_a?(Hash)
246
- if data = build_multipart(params)
315
+ if !params.empty? && data = build_multipart(params, false, multipart)
247
316
  env[:input] = data
248
317
  env['CONTENT_LENGTH'] ||= data.length.to_s
249
- env['CONTENT_TYPE'] = "multipart/form-data; boundary=#{MULTIPART_BOUNDARY}"
318
+ env['CONTENT_TYPE'] = "#{multipart_content_type(env)}; boundary=#{MULTIPART_BOUNDARY}"
250
319
  else
251
- # NB: We do not need to set CONTENT_LENGTH here;
252
- # Rack::ContentLength will determine it automatically.
253
- env[:input] = params_to_string(params)
320
+ env[:input] = build_nested_query(params)
254
321
  end
255
322
  else
256
323
  env[:input] = params
257
324
  end
258
325
  end
259
326
 
327
+ if query_params = env.delete(:query_params)
328
+ append_query_params(query_array, query_params)
329
+ end
330
+ query_array.compact!
331
+ query_array.reject!(&:empty?)
332
+ uri.query = query_array.join('&')
333
+
260
334
  set_cookie(env.delete(:cookie), uri) if env.key?(:cookie)
261
335
 
262
336
  Rack::MockRequest.env_for(uri.to_s, env)
263
337
  end
264
338
 
265
- def process_request(uri, env)
266
- @rack_mock_session.request(uri, env)
339
+ # Append a string version of the query params to the array of query params.
340
+ def append_query_params(query_array, query_params)
341
+ query_params = parse_nested_query(query_params) if query_params.is_a?(String)
342
+ query_array << build_nested_query(query_params)
343
+ end
267
344
 
268
- if retry_with_digest_auth?(env)
269
- auth_env = env.merge('HTTP_AUTHORIZATION' => digest_auth_header,
270
- 'rack-test.digest_auth_retry' => true)
271
- auth_env.delete('rack.request')
272
- process_request(uri.path, auth_env)
345
+ # Return the multipart content type to use based on the environment.
346
+ def multipart_content_type(env)
347
+ requested_content_type = env['CONTENT_TYPE']
348
+ if requested_content_type.start_with?('multipart/')
349
+ requested_content_type
273
350
  else
274
- yield last_response if block_given?
275
-
276
- last_response
351
+ 'multipart/form-data'
277
352
  end
278
353
  end
279
354
 
280
- def digest_auth_header
281
- challenge = last_response['WWW-Authenticate'].split(' ', 2).last
282
- params = Rack::Auth::Digest::Params.parse(challenge)
283
-
284
- params.merge!('username' => @digest_username,
285
- 'nc' => '00000001',
286
- 'cnonce' => 'nonsensenonce',
287
- 'uri' => last_request.fullpath,
288
- 'method' => last_request.env['REQUEST_METHOD'])
289
-
290
- params['response'] = MockDigestRequest.new(params).response(@digest_password)
291
-
292
- "Digest #{params}"
293
- end
294
-
295
- def retry_with_digest_auth?(env)
296
- last_response.status == 401 &&
297
- digest_auth_configured? &&
298
- !env['rack-test.digest_auth_retry']
299
- end
300
-
301
- def digest_auth_configured?
302
- @digest_username
303
- end
304
-
305
- def default_env
306
- { 'rack.test' => true, 'REMOTE_ADDR' => '127.0.0.1' }.merge(@env).merge(headers_for_env)
307
- end
308
-
309
- def headers_for_env
310
- converted_headers = {}
355
+ # Submit the request with the given URI and rack environment to
356
+ # the mock session. Returns and potentially yields the last response.
357
+ def process_request(uri, env)
358
+ env['HTTP_COOKIE'] ||= cookie_jar.for(uri)
359
+ @last_request = Rack::Request.new(env)
360
+ status, headers, body = @app.call(env).to_a
311
361
 
312
- @headers.each do |name, value|
313
- env_key = name.upcase.tr('-', '_')
314
- env_key = 'HTTP_' + env_key unless env_key == 'CONTENT_TYPE'
315
- converted_headers[env_key] = value
316
- end
362
+ @last_response = MockResponse.new(status, headers, body, env['rack.errors'].flush)
363
+ close_body(body)
364
+ cookie_jar.merge(last_response.headers['set-cookie'], uri)
365
+ @after_request.each(&:call)
366
+ @last_response.finish
317
367
 
318
- converted_headers
319
- end
368
+ yield @last_response if block_given?
320
369
 
321
- def params_to_string(params)
322
- case params
323
- when Hash then build_nested_query(params)
324
- when nil then ''
325
- else params
326
- end
370
+ @last_response
327
371
  end
328
372
  end
329
373
 
374
+ # Whether the version of rack in use handles encodings.
330
375
  def self.encoding_aware_strings?
331
- defined?(Encoding) && ''.respond_to?(:encode)
376
+ Rack.release >= '1.6'
332
377
  end
333
378
  end
379
+
380
+ # For backwards compatibility with 1.1.0 and below
381
+ MockSession = Test::Session
334
382
  end