require 'uri' require 'rack' require 'rack/mock_session' require 'rack/test/cookie_jar' require 'rack/test/mock_digest_request' require 'rack/test/utils' require 'rack/test/methods' require 'rack/test/uploaded_file' require 'rack/test/version' module Rack module Test DEFAULT_HOST = 'example.org'.freeze MULTIPART_BOUNDARY = '----------XnJLe9ZIbbGUYtzPQJ16u1'.freeze # The common base class for exceptions raised by Rack::Test class Error < StandardError; end # This class represents a series of requests issued to a Rack app, sharing # a single cookie jar # # Rack::Test::Session's methods are most often called through Rack::Test::Methods, # which will automatically build a session when it's first used. class Session extend Forwardable include Rack::Test::Utils def_delegators :@rack_mock_session, :clear_cookies, :set_cookie, :last_response, :last_request # Creates a Rack::Test::Session for a given Rack app or Rack::MockSession. # # Note: Generally, you won't need to initialize a Rack::Test::Session directly. # Instead, you should include Rack::Test::Methods into your testing context. # (See README.rdoc for an example) def initialize(mock_session) @headers = {} @env = {} @digest_username = nil @digest_password = nil @rack_mock_session = if mock_session.is_a?(MockSession) mock_session else MockSession.new(mock_session) end @default_host = @rack_mock_session.default_host end # Issue a GET request for the given URI with the given params and Rack # environment. Stores the issues request object in #last_request and # the app's response in #last_response. Yield #last_response to a block # if given. # # Example: # get "/" def get(uri, params = {}, env = {}, &block) custom_request('GET', uri, params, env, &block) end # Issue a POST request for the given URI. See #get # # Example: # post "/signup", "name" => "Bryan" def post(uri, params = {}, env = {}, &block) custom_request('POST', uri, params, env, &block) end # Issue a PUT request for the given URI. See #get # # Example: # put "/" def put(uri, params = {}, env = {}, &block) custom_request('PUT', uri, params, env, &block) end # Issue a PATCH request for the given URI. See #get # # Example: # patch "/" def patch(uri, params = {}, env = {}, &block) custom_request('PATCH', uri, params, env, &block) end # Issue a DELETE request for the given URI. See #get # # Example: # delete "/" def delete(uri, params = {}, env = {}, &block) custom_request('DELETE', uri, params, env, &block) end # Issue an OPTIONS request for the given URI. See #get # # Example: # options "/" def options(uri, params = {}, env = {}, &block) custom_request('OPTIONS', uri, params, env, &block) end # Issue a HEAD request for the given URI. See #get # # Example: # head "/" def head(uri, params = {}, env = {}, &block) custom_request('HEAD', uri, params, env, &block) end # Issue a request to the Rack app for the given URI and optional Rack # environment. Stores the issues request object in #last_request and # the app's response in #last_response. Yield #last_response to a block # if given. # # Example: # request "/" def request(uri, env = {}, &block) uri = parse_uri(uri, env) env = env_for(uri, env) process_request(uri, env, &block) end # Issue a request using the given verb for the given URI. See #get # # Example: # custom_request "LINK", "/" def custom_request(verb, uri, params = {}, env = {}, &block) uri = parse_uri(uri, env) env = env_for(uri, env.merge(method: verb.to_s.upcase, params: params)) process_request(uri, env, &block) end # Set a header to be included on all subsequent requests through the # session. Use a value of nil to remove a previously configured header. # # In accordance with the Rack spec, headers will be included in the Rack # environment hash in HTTP_USER_AGENT form. # # Example: # header "User-Agent", "Firefox" def header(name, value) if value.nil? @headers.delete(name) else @headers[name] = value end end # Set an env var to be included on all subsequent requests through the # session. Use a value of nil to remove a previously configured env. # # Example: # env "rack.session", {:csrf => 'token'} def env(name, value) if value.nil? @env.delete(name) else @env[name] = value end end # Set the username and password for HTTP Basic authorization, to be # included in subsequent requests in the HTTP_AUTHORIZATION header. # # Example: # basic_authorize "bryan", "secret" def basic_authorize(username, password) encoded_login = ["#{username}:#{password}"].pack('m0') header('Authorization', "Basic #{encoded_login}") end alias authorize basic_authorize # Set the username and password for HTTP Digest authorization, to be # included in subsequent requests in the HTTP_AUTHORIZATION header. # # Example: # digest_authorize "bryan", "secret" def digest_authorize(username, password) @digest_username = username @digest_password = password end # Rack::Test will not follow any redirects automatically. This method # will follow the redirect returned (including setting the Referer header # on the new request) in the last response. If the last response was not # a redirect, an error will be raised. def follow_redirect! unless last_response.redirect? raise Error, 'Last response was not a redirect. Cannot follow_redirect!' end request_method, params = if last_response.status == 307 [last_request.request_method.downcase.to_sym, last_request.params] else [:get, {}] end # Compute the next location by appending the location header with the # last request, as per https://tools.ietf.org/html/rfc7231#section-7.1.2 # Adding two absolute locations returns the right-hand location next_location = URI.parse(last_request.url) + URI.parse(last_response['Location']) send( request_method, next_location.to_s, params, 'HTTP_REFERER' => last_request.url, 'rack.session' => last_request.session, 'rack.session.options' => last_request.session_options ) end private def parse_uri(path, env) URI.parse(path).tap do |uri| uri.path = "/#{uri.path}" unless uri.path[0] == '/' uri.host ||= @default_host uri.scheme ||= 'https' if env['HTTPS'] == 'on' end end def env_for(uri, env) env = default_env.merge(env) env['HTTP_HOST'] ||= [uri.host, (uri.port if uri.port != uri.default_port)].compact.join(':') env.update('HTTPS' => 'on') if URI::HTTPS === uri env['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' if env[:xhr] # TODO: Remove this after Rack 1.1 has been released. # Stringifying and upcasing methods has be commit upstream env['REQUEST_METHOD'] ||= env[:method] ? env[:method].to_s.upcase : 'GET' params = env.delete(:params) do {} end if env['REQUEST_METHOD'] == 'GET' # merge :params with the query string if params params = parse_nested_query(params) if params.is_a?(String) uri.query = [uri.query, build_nested_query(params)].compact.reject { |v| v == '' }.join('&') end elsif !env.key?(:input) env['CONTENT_TYPE'] ||= 'application/x-www-form-urlencoded' if params.is_a?(Hash) if data = build_multipart(params) env[:input] = data env['CONTENT_LENGTH'] ||= data.length.to_s env['CONTENT_TYPE'] = "multipart/form-data; boundary=#{MULTIPART_BOUNDARY}" else # NB: We do not need to set CONTENT_LENGTH here; # Rack::ContentLength will determine it automatically. env[:input] = params_to_string(params) end else env[:input] = params end end set_cookie(env.delete(:cookie), uri) if env.key?(:cookie) Rack::MockRequest.env_for(uri.to_s, env) end def process_request(uri, env) @rack_mock_session.request(uri, env) if retry_with_digest_auth?(env) auth_env = env.merge('HTTP_AUTHORIZATION' => digest_auth_header, 'rack-test.digest_auth_retry' => true) auth_env.delete('rack.request') process_request(uri.path, auth_env) else yield last_response if block_given? last_response end end def digest_auth_header challenge = last_response['WWW-Authenticate'].split(' ', 2).last params = Rack::Auth::Digest::Params.parse(challenge) params.merge!('username' => @digest_username, 'nc' => '00000001', 'cnonce' => 'nonsensenonce', 'uri' => last_request.fullpath, 'method' => last_request.env['REQUEST_METHOD']) params['response'] = MockDigestRequest.new(params).response(@digest_password) "Digest #{params}" end def retry_with_digest_auth?(env) last_response.status == 401 && digest_auth_configured? && !env['rack-test.digest_auth_retry'] end def digest_auth_configured? @digest_username end def default_env { 'rack.test' => true, 'REMOTE_ADDR' => '127.0.0.1' }.merge(@env).merge(headers_for_env) end def headers_for_env converted_headers = {} @headers.each do |name, value| env_key = name.upcase.tr('-', '_') env_key = 'HTTP_' + env_key unless env_key == 'CONTENT_TYPE' converted_headers[env_key] = value end converted_headers end def params_to_string(params) case params when Hash then build_nested_query(params) when nil then '' else params end end end def self.encoding_aware_strings? defined?(Encoding) && ''.respond_to?(:encode) end end end