├── spec ├── fixtures │ ├── mb.txt │ ├── bar.txt │ ├── foo.txt │ ├── space case.txt │ ├── config.ru │ └── fake_app.rb ├── all.rb ├── spec_helper.rb └── rack │ ├── test │ ├── methods_spec.rb │ ├── cookie_object_spec.rb │ ├── cookie_jar_spec.rb │ ├── uploaded_file_spec.rb │ ├── multipart_spec.rb │ ├── cookie_spec.rb │ └── utils_spec.rb │ └── test_spec.rb ├── .document ├── lib └── rack │ ├── test │ ├── version.rb │ ├── methods.rb │ ├── uploaded_file.rb │ ├── utils.rb │ └── cookie_jar.rb │ └── test.rb ├── .gitignore ├── Gemfile.rack-1.x ├── Gemfile ├── Rakefile ├── .github └── workflows │ └── ci.yml ├── MIT-LICENSE.txt ├── rack-test.gemspec ├── README.md └── History.md /spec/fixtures/mb.txt: -------------------------------------------------------------------------------- 1 | ⍅ -------------------------------------------------------------------------------- /spec/fixtures/bar.txt: -------------------------------------------------------------------------------- 1 | baz 2 | -------------------------------------------------------------------------------- /spec/fixtures/foo.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /spec/fixtures/space case.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.md 2 | lib/**/*.rb 3 | History.txt 4 | MIT-LICENSE.txt 5 | -------------------------------------------------------------------------------- /spec/fixtures/config.ru: -------------------------------------------------------------------------------- 1 | require 'fake_app' 2 | 3 | run Rack::Test::FakeApp 4 | -------------------------------------------------------------------------------- /spec/all.rb: -------------------------------------------------------------------------------- 1 | Dir.glob('spec/**/*_spec.rb') {|f| require_relative f.sub('spec/', '')} 2 | -------------------------------------------------------------------------------- /lib/rack/test/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Test 3 | VERSION = '2.2.0'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | doc 3 | coverage 4 | /Gemfile*.lock 5 | VERSION 6 | *.rbc 7 | *.swp 8 | /rack-test-*.gem 9 | /.bundle 10 | /.idea 11 | -------------------------------------------------------------------------------- /Gemfile.rack-1.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | # Runtime dependency 6 | if RUBY_VERSION < '2.1' 7 | gem 'rack', '< 1.4' 8 | else 9 | gem 'rack', '< 2' 10 | end 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | # Runtime dependency 6 | if RUBY_VERSION >= '3.1' 7 | if RUBY_VERSION < '3.2' 8 | gem 'cgi', '0.3.6' 9 | end 10 | gem 'rack', github: 'rack/rack' 11 | else 12 | gem 'rack', '~> 2.0' 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | task default: :spec 2 | 3 | desc "Run specs" 4 | task "spec" do 5 | sh "#{FileUtils::RUBY} -w #{'-W:strict_unused_block' if RUBY_VERSION >= '3.4'} spec/all.rb" 6 | end 7 | 8 | desc "Run specs with coverage" 9 | task "spec_cov" do 10 | ENV['COVERAGE'] = '1' 11 | sh "#{FileUtils::RUBY} -w spec/all.rb" 12 | ENV.delete('COVERAGE') 13 | end 14 | 15 | desc 'Generate RDoc' 16 | task :docs do 17 | FileUtils.rm_rf('doc') 18 | require_relative 'lib/rack/test/version' 19 | sh "rdoc --title 'Rack::Test #{Rack::Test::VERSION} API Documentation'" 20 | end 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ ubuntu-latest ] 14 | ruby: [ 2.0.0, 2.1, 2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2, 3.3, 3.4, "4.0", jruby-9.3, jruby-9.4, jruby-10.0 ] 15 | gemfile: [ Gemfile, Gemfile.rack-1.x ] 16 | exclude: 17 | - ruby: 2.0.0 18 | gemfile: Gemfile 19 | - ruby: 2.1 20 | gemfile: Gemfile 21 | runs-on: ${{ matrix.os }} 22 | env: 23 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ matrix.ruby }} 30 | bundler-cache: true 31 | 32 | - run: bundle exec rake spec 33 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if ENV.delete('COVERAGE') 2 | require 'simplecov' 3 | SimpleCov.start do 4 | enable_coverage :branch 5 | add_filter{|f| f.filename.match(%r{\A#{Regexp.escape(__dir__)}/})} 6 | add_group('Missing'){|src| src.covered_percent < 100} 7 | add_group('Covered'){|src| src.covered_percent == 100} 8 | end 9 | end 10 | 11 | ENV['MT_NO_PLUGINS'] = '1' # Work around stupid autoloading of plugins 12 | gem 'minitest' 13 | require 'minitest/global_expectations/autorun' 14 | 15 | require_relative '../lib/rack/test' 16 | require_relative 'fixtures/fake_app' 17 | 18 | class Minitest::Spec 19 | include Rack::Test::Methods 20 | 21 | def app 22 | Rack::Test::FAKE_APP 23 | end 24 | 25 | def self.deprecated(*args, &block) 26 | it(*args) do 27 | begin 28 | verbose, $VERBOSE = $VERBOSE, nil 29 | instance_exec(&block) 30 | ensure 31 | $VERBOSE = verbose 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2009 Bryan Helmkamp, Engine Yard Inc. 2 | Copyright (c) 2022 Jeremy Evans 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /rack-test.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/rack/test/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'rack-test' 5 | s.version = Rack::Test::VERSION 6 | s.platform = Gem::Platform::RUBY 7 | s.author = ['Jeremy Evans', 'Bryan Helmkamp'] 8 | s.email = ['code@jeremyevans.net', 'bryan@brynary.com'] 9 | s.license = 'MIT' 10 | s.homepage = 'https://github.com/rack/rack-test' 11 | s.summary = 'Simple testing API built on Rack' 12 | s.description = <<-EOS.strip 13 | Rack::Test is a small, simple testing API for Rack apps. It can be used on its 14 | own or as a reusable starting point for Web frameworks and testing libraries 15 | to build on. 16 | EOS 17 | s.metadata = { 18 | 'source_code_uri' => 'https://github.com/rack/rack-test', 19 | 'bug_tracker_uri' => 'https://github.com/rack/rack-test/issues', 20 | 'mailing_list_uri' => 'https://github.com/rack/rack-test/discussions', 21 | 'changelog_uri' => 'https://github.com/rack/rack-test/blob/main/History.md', 22 | } 23 | s.require_paths = ['lib'] 24 | s.files = `git ls-files -- lib/*`.split("\n") + 25 | %w[History.md MIT-LICENSE.txt README.md] 26 | s.required_ruby_version = '>= 2.0' 27 | s.add_dependency 'rack', '>= 1.3' 28 | s.add_development_dependency 'rake' 29 | s.add_development_dependency 'minitest', ">= 5.0" 30 | s.add_development_dependency 'minitest-global_expectations' 31 | end 32 | -------------------------------------------------------------------------------- /spec/rack/test/methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | 5 | describe 'Rack::Test::Methods' do 6 | it '#rack_mock_session always creates new session if passed nil/false' do 7 | rack_mock_session(nil).wont_be_same_as rack_mock_session(nil) 8 | rack_mock_session(false).wont_be_same_as rack_mock_session(false) 9 | end 10 | 11 | it '#rack_mock_session reuses existing session if passed truthy value' do 12 | rack_mock_session(true).must_be_same_as rack_mock_session(true) 13 | rack_mock_session(:true).must_be_same_as rack_mock_session(:true) 14 | end 15 | 16 | it '#rack_test_session always creates new session if passed nil/false' do 17 | rack_test_session(nil).wont_be_same_as rack_test_session(nil) 18 | rack_test_session(false).wont_be_same_as rack_test_session(false) 19 | end 20 | 21 | it '#rack_test_session reuses existing session if passed truthy value' do 22 | rack_test_session(true).must_be_same_as rack_test_session(true) 23 | rack_test_session(:true).must_be_same_as rack_test_session(:true) 24 | end 25 | 26 | it '#build_rack_mock_session will be used if present' do 27 | session = Rack::Test::Session.new(app) 28 | define_singleton_method(:build_rack_mock_session){session} 29 | current_session.must_be_same_as session 30 | end 31 | 32 | it '#build_rack_test_session will use defined app' do 33 | envs = [] 34 | app = proc{|env| envs << env; [200, {}, []]} 35 | define_singleton_method(:app){app} 36 | 37 | get '/' 38 | envs.first['PATH_INFO'].must_equal '/' 39 | envs.first['HTTP_HOST'].must_equal 'example.org' 40 | end 41 | 42 | it '#build_rack_test_session will use defined default_host' do 43 | envs = [] 44 | app = proc{|env| envs << env; [200, {}, []]} 45 | define_singleton_method(:app){app} 46 | define_singleton_method(:default_host){'foo.example.com'} 47 | 48 | get '/' 49 | envs.first['PATH_INFO'].must_equal '/' 50 | envs.first['HTTP_HOST'].must_equal 'foo.example.com' 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/rack/test/cookie_object_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | require 'cgi' 5 | 6 | describe Rack::Test::Cookie do 7 | value = 'the cookie value'.freeze 8 | domain = 'www.example.org'.freeze 9 | path = '/foo'.freeze 10 | expires = (Time.now + (24 * 60 * 60)).httpdate 11 | cookie_string = [ 12 | 'cookie_name=' + CGI.escape(value), 13 | 'domain=' + domain, 14 | 'path=' + path, 15 | 'expires=' + expires 16 | ].join(Rack::Test::CookieJar::DELIMITER).freeze 17 | 18 | define_method(:cookie) do |trailer=''| 19 | Rack::Test::Cookie.new(cookie_string + trailer) 20 | end 21 | 22 | it '#to_h returns the cookie value and all options' do 23 | cookie('; HttpOnly; secure').to_h.must_equal( 24 | 'value' => value, 25 | 'domain' => domain, 26 | 'path' => path, 27 | 'expires' => expires, 28 | 'HttpOnly' => true, 29 | 'secure' => true 30 | ) 31 | end 32 | 33 | it '#to_hash is an alias for #to_h' do 34 | cookie.to_hash.must_equal cookie.to_h 35 | end 36 | 37 | it '#empty? should only be true for empty values' do 38 | cookie.empty?.must_equal false 39 | Rack::Test::Cookie.new('value=').empty?.must_equal true 40 | end 41 | 42 | it '#valid? should consider the given URI scheme for secure cookies' do 43 | cookie('; secure').valid?(URI.parse('https://www.example.org/')).must_equal true 44 | cookie('; secure').valid?(URI.parse('httpx://www.example.org/')).must_equal false 45 | cookie('; secure').valid?(URI.parse('/')).must_equal false 46 | end 47 | 48 | it '#valid? is indifferent to matching paths' do 49 | cookie.valid?(URI.parse('https://www.example.org/foo')).must_equal true 50 | cookie.valid?(URI.parse('https://www.example.org/bar')).must_equal true 51 | end 52 | 53 | it '#matches? demands matching paths' do 54 | cookie.matches?(URI.parse('https://www.example.org/foo')).must_equal true 55 | cookie.matches?(URI.parse('https://www.example.org/bar')).must_equal false 56 | end 57 | 58 | it '#http_only? for a non HTTP only cookie returns false' do 59 | cookie.http_only?.must_equal false 60 | end 61 | 62 | it '#http_only? for an HTTP only cookie returns true' do 63 | cookie('; HttpOnly').http_only?.must_equal true 64 | end 65 | 66 | it '#http_only? for an HTTP only cookie returns true' do 67 | cookie('; httponly').http_only?.must_equal true 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/rack/test/cookie_jar_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | 5 | describe Rack::Test::CookieJar do 6 | cookie_value = 'foo;abc'.freeze 7 | cookie_name = 'a_cookie_name'.freeze 8 | 9 | it 'copies should not share a cookie jar' do 10 | jar = Rack::Test::CookieJar.new 11 | jar_dup = jar.dup 12 | jar_clone = jar.clone 13 | 14 | jar['a'] = 'b' 15 | jar.to_hash.must_equal 'a' => 'b' 16 | jar_dup.to_hash.must_be_empty 17 | jar_clone.to_hash.must_be_empty 18 | end 19 | 20 | it 'ignores leading dot in domain' do 21 | jar = Rack::Test::CookieJar.new 22 | jar << Rack::Test::Cookie.new('a=c; domain=.lithostech.com', URI('https://lithostech.com')) 23 | jar.get_cookie('a').domain.must_equal 'lithostech.com' 24 | end 25 | 26 | it '#[] and []= should get and set cookie values' do 27 | jar = Rack::Test::CookieJar.new 28 | jar[cookie_name].must_be_nil 29 | jar[cookie_name] = cookie_value 30 | jar[cookie_name].must_equal cookie_value 31 | jar[cookie_name+'a'].must_be_nil 32 | end 33 | 34 | it '#get_cookie with a populated jar returns full cookie objects' do 35 | jar = Rack::Test::CookieJar.new 36 | jar.get_cookie(cookie_name).must_be_nil 37 | jar[cookie_name] = cookie_value 38 | jar.get_cookie(cookie_name).must_be_kind_of Rack::Test::Cookie 39 | jar.get_cookie(cookie_name+'a').must_be_nil 40 | end 41 | 42 | it '#for returns the cookie header string delimited by semicolon and a space' do 43 | jar = Rack::Test::CookieJar.new 44 | jar['a'] = 'b' 45 | jar['c'] = 'd' 46 | 47 | jar.for(nil).must_equal 'a=b; c=d' 48 | end 49 | 50 | it '#to_hash returns a hash of cookies' do 51 | jar = Rack::Test::CookieJar.new 52 | jar['a'] = 'b' 53 | jar['c'] = 'd' 54 | jar.to_hash.must_equal 'a' => 'b', 'c' => 'd' 55 | end 56 | 57 | it '#merge merges valid raw cookie strings' do 58 | jar = Rack::Test::CookieJar.new 59 | jar['a'] = 'b' 60 | jar.merge('c=d') 61 | jar.to_hash.must_equal 'a' => 'b', 'c' => 'd' 62 | end 63 | 64 | it '#merge does not merge invalid raw cookie strings' do 65 | jar = Rack::Test::CookieJar.new 66 | jar['a'] = 'b' 67 | jar.merge('c=d; domain=example.org; secure', URI.parse('/')) 68 | jar.to_hash.must_equal 'a' => 'b' 69 | end 70 | 71 | it '#merge ignores empty cookies in cookie strings' do 72 | jar = Rack::Test::CookieJar.new 73 | jar.merge('', URI.parse('/')) 74 | jar.merge("\nc=d") 75 | jar.to_hash.must_equal 'c' => 'd' 76 | end 77 | 78 | it '#merge ignores empty cookies in cookie arrays' do 79 | jar = Rack::Test::CookieJar.new 80 | jar.merge(['', 'c=d'], URI.parse('/')) 81 | jar.to_hash.must_equal 'c' => 'd' 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/rack/test/uploaded_file_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | 5 | describe Rack::Test::UploadedFile do 6 | def file_path 7 | File.dirname(__FILE__) + '/../../fixtures/foo.txt' 8 | end 9 | 10 | it 'returns an instance of `Rack::Test::UploadedFile`' do 11 | uploaded_file = Rack::Test::UploadedFile.new(file_path) 12 | 13 | uploaded_file.class.must_equal Rack::Test::UploadedFile 14 | end 15 | 16 | it 'responds to things that Tempfile responds to' do 17 | uploaded_file = Rack::Test::UploadedFile.new(file_path) 18 | 19 | Tempfile.public_instance_methods(false).each do |method| 20 | uploaded_file.must_respond_to method 21 | end 22 | end 23 | 24 | it "creates Tempfiles with original file's extension" do 25 | uploaded_file = Rack::Test::UploadedFile.new(file_path) 26 | 27 | File.extname(uploaded_file.path).must_equal '.txt' 28 | end 29 | 30 | it 'creates Tempfiles with a path that includes a single extension' do 31 | uploaded_file = Rack::Test::UploadedFile.new(file_path) 32 | 33 | regex = /foo#{Time.now.year}.*\.txt\Z/ 34 | uploaded_file.path.must_match regex 35 | end 36 | 37 | it 'allows to override the Tempfiles original_filename' do 38 | uploaded_file = Rack::Test::UploadedFile.new(file_path, original_filename: 'bar.txt') 39 | regex = /bar#{Time.now.year}.*\.txt\Z/ 40 | 41 | uploaded_file.path.must_match regex 42 | end 43 | 44 | it 'respects binary argument' do 45 | Rack::Test::UploadedFile.new(file_path, 'text/plain', true).tempfile.must_be :binmode? 46 | Rack::Test::UploadedFile.new(file_path, 'text/plain', false).tempfile.wont_be :binmode? 47 | Rack::Test::UploadedFile.new(file_path, 'text/plain').tempfile.wont_be :binmode? 48 | end 49 | 50 | it 'raises for invalid files' do 51 | proc{Rack::Test::UploadedFile.new('does_not_exist')}.must_raise RuntimeError 52 | end 53 | 54 | def local_paths(n) 55 | local_paths = n.times.map do 56 | Rack::Test::UploadedFile.new(file_path) 57 | end 58 | local_paths.map(&:local_path).all?{|f| File.exist?(f)}.must_equal true 59 | local_paths.map!(&:local_path) 60 | local_paths.uniq.size.must_equal n 61 | local_paths 62 | end 63 | 64 | it 'removes local paths on garbage collection' do 65 | skip "flaky on JRuby" if RUBY_PLATFORM == 'java' 66 | paths = local_paths(50) 67 | GC.start 68 | paths.all?{|f| File.exist?(f)}.must_equal false 69 | end 70 | 71 | it '#initialize with an IO object sets the specified filename' do 72 | original_filename = 'content.txt' 73 | uploaded_file = Rack::Test::UploadedFile.new(StringIO.new('I am content'), original_filename: original_filename) 74 | uploaded_file.original_filename.must_equal original_filename 75 | end 76 | 77 | it '#initialize without an original filename raises an error' do 78 | proc { Rack::Test::UploadedFile.new(StringIO.new('I am content')) }.must_raise(ArgumentError, 'Missing `original_filename` for StringIO object') 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/rack/test/methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Rack 6 | module Test 7 | # This module serves as the primary integration point for using Rack::Test 8 | # in a testing environment. It depends on an app method being defined in the 9 | # same context, and provides the Rack::Test API methods (see Rack::Test::Session 10 | # for their documentation). It defines the following methods that are delegated 11 | # to the current session: :request, :get, :post, :put, :patch, :delete, :options, 12 | # :head, :custom_request, :follow_redirect!, :header, :env, :set_cookie, 13 | # :clear_cookies, :authorize, :basic_authorize, :last_response, and :last_request. 14 | # 15 | # Example: 16 | # 17 | # class HomepageTest < Test::Unit::TestCase 18 | # include Rack::Test::Methods 19 | # 20 | # def app 21 | # MyApp 22 | # end 23 | # end 24 | module Methods 25 | extend Forwardable 26 | 27 | # Return the existing session with the given name, or a new 28 | # rack session. Always use a new session if name is nil. 29 | def rack_test_session(name = :default) # :nodoc: 30 | return build_rack_test_session(name) unless name 31 | 32 | @_rack_test_sessions ||= {} 33 | @_rack_test_sessions[name] ||= build_rack_test_session(name) 34 | end 35 | 36 | # For backwards compatibility with older rack-test versions. 37 | alias rack_mock_session rack_test_session # :nodoc: 38 | 39 | # Create a new Rack::Test::Session for #app. 40 | def build_rack_test_session(_name) # :nodoc: 41 | if respond_to?(:build_rack_mock_session, true) 42 | # Backwards compatibility for capybara 43 | build_rack_mock_session 44 | else 45 | if respond_to?(:default_host) 46 | Session.new(app, default_host) 47 | else 48 | Session.new(app) 49 | end 50 | end 51 | end 52 | 53 | # Return the currently actively session. This is the session to 54 | # which the delegated methods are sent. 55 | def current_session 56 | @_rack_test_current_session ||= rack_test_session 57 | end 58 | 59 | # Create a new session (or reuse an existing session with the given name), 60 | # and make it the current session for the given block. 61 | def with_session(name) 62 | session = _rack_test_current_session 63 | yield(@_rack_test_current_session = rack_test_session(name)) 64 | ensure 65 | @_rack_test_current_session = session 66 | end 67 | 68 | def_delegators(:current_session, 69 | :request, 70 | :get, 71 | :post, 72 | :put, 73 | :patch, 74 | :delete, 75 | :options, 76 | :head, 77 | :custom_request, 78 | :follow_redirect!, 79 | :header, 80 | :env, 81 | :set_cookie, 82 | :clear_cookies, 83 | :authorize, 84 | :basic_authorize, 85 | :last_response, 86 | :last_request, 87 | ) 88 | 89 | # Private accessor to avoid uninitialized instance variable warning in Ruby 2.* 90 | attr_accessor :_rack_test_current_session 91 | private :_rack_test_current_session 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/rack/test/uploaded_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'tempfile' 5 | require 'stringio' 6 | 7 | module Rack 8 | module Test 9 | # Wraps a Tempfile with a content type. Including one or more UploadedFile's 10 | # in the params causes Rack::Test to build and issue a multipart request. 11 | # 12 | # Example: 13 | # post "/photos", "file" => Rack::Test::UploadedFile.new("me.jpg", "image/jpeg") 14 | class UploadedFile 15 | # The filename, *not* including the path, of the "uploaded" file 16 | attr_reader :original_filename 17 | 18 | # The tempfile 19 | attr_reader :tempfile 20 | 21 | # The content type of the "uploaded" file 22 | attr_accessor :content_type 23 | 24 | # Creates a new UploadedFile instance. 25 | # 26 | # Arguments: 27 | # content :: is a path to a file, or an {IO} or {StringIO} object representing the content. 28 | # content_type :: MIME type of the file 29 | # binary :: Whether the file should be set to binmode (content treated as binary). 30 | # original_filename :: The filename to use for the file. Required if content is StringIO, optional override if not 31 | def initialize(content, content_type = 'text/plain', binary = false, original_filename: nil) 32 | @content_type = content_type 33 | @original_filename = original_filename 34 | 35 | case content 36 | when StringIO 37 | initialize_from_stringio(content) 38 | else 39 | initialize_from_file_path(content) 40 | end 41 | 42 | @tempfile.binmode if binary 43 | end 44 | 45 | # The path to the tempfile. Will not work if the receiver's content is from a StringIO. 46 | def path 47 | tempfile.path 48 | end 49 | alias local_path path 50 | 51 | # Delegate all methods not handled to the tempfile. 52 | def method_missing(method_name, *args, &block) 53 | tempfile.public_send(method_name, *args, &block) 54 | end 55 | 56 | # Append to given buffer in 64K chunks to avoid multiple large 57 | # copies of file data in memory. Rewind tempfile before and 58 | # after to make sure all data in tempfile is appended to the 59 | # buffer. 60 | def append_to(buffer) 61 | tempfile.rewind 62 | 63 | buf = String.new 64 | buffer << tempfile.readpartial(65_536, buf) until tempfile.eof? 65 | 66 | tempfile.rewind 67 | 68 | nil 69 | end 70 | 71 | def respond_to_missing?(method_name, include_private = false) #:nodoc: 72 | tempfile.respond_to?(method_name, include_private) || super 73 | end 74 | 75 | private 76 | 77 | # Use the StringIO as the tempfile. 78 | def initialize_from_stringio(stringio) 79 | raise(ArgumentError, 'Missing `original_filename` for StringIO object') unless @original_filename 80 | 81 | @tempfile = stringio 82 | end 83 | 84 | # Create a tempfile and copy the content from the given path into the tempfile, optionally renaming if 85 | # original_filename has been set. 86 | def initialize_from_file_path(path) 87 | raise "#{path} file does not exist" unless ::File.exist?(path) 88 | 89 | @original_filename ||= ::File.basename(path) 90 | extension = ::File.extname(@original_filename) 91 | 92 | @tempfile = Tempfile.new([::File.basename(@original_filename, extension), extension]) 93 | @tempfile.set_encoding(Encoding::BINARY) 94 | 95 | FileUtils.copy_file(path, @tempfile.path) 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rack::Test 2 | [![Gem Version](https://badge.fury.io/rb/rack-test.svg)](https://badge.fury.io/rb/rack-test) 3 | 4 | Code: https://github.com/rack/rack-test 5 | 6 | ## Description 7 | 8 | Rack::Test is a small, simple testing API for Rack apps. It can be used on its 9 | own or as a reusable starting point for Web frameworks and testing libraries 10 | to build on. 11 | 12 | ## Features 13 | 14 | * Allows for submitting requests and testing responses 15 | * Maintains a cookie jar across requests 16 | * Supports request headers used for subsequent requests 17 | * Follow redirects when requested 18 | 19 | ## Examples 20 | 21 | These examples use `test/unit` but it's equally possible to use `rack-test` with 22 | other testing frameworks such as `minitest` or `rspec`. 23 | 24 | ```ruby 25 | require "test/unit" 26 | require "rack/test" 27 | require "json" 28 | 29 | class HomepageTest < Test::Unit::TestCase 30 | include Rack::Test::Methods 31 | 32 | def app 33 | lambda { |env| [200, {'content-type' => 'text/plain'}, ['All responses are OK']] } 34 | end 35 | 36 | def test_response_is_ok 37 | # Optionally set headers used for all requests in this spec: 38 | #header 'accept-charset', 'utf-8' 39 | 40 | # First argument is treated as the path 41 | get '/' 42 | 43 | assert last_response.ok? 44 | assert_equal 'All responses are OK', last_response.body 45 | end 46 | 47 | def delete_with_url_params_and_body 48 | # First argument can have a query string 49 | # 50 | # Second argument is used as the parameters for the request, which will be 51 | # included in the request body for non-GET requests. 52 | delete '/?foo=bar', JSON.generate('baz' => 'zot') 53 | end 54 | 55 | def post_with_json 56 | # Third argument is the rack environment to use for the request. The following 57 | # entries in the submitted rack environment are treated specially (in addition 58 | # to options supported by `Rack::MockRequest#env_for`: 59 | # 60 | # :cookie : Set a cookie for the current session before submitting the request. 61 | # 62 | # :query_params : Set parameters for the query string (as opposed to the body). 63 | # Value should be a hash of parameters. 64 | # 65 | # :xhr : Set HTTP_X_REQUESTED_WITH env key to XMLHttpRequest. 66 | post(uri, JSON.generate('baz' => 'zot'), 'CONTENT_TYPE' => 'application/json') 67 | end 68 | end 69 | ``` 70 | 71 | `rack-test` will test the app returned by the `app` method. If you are loading middleware 72 | in a `config.ru` file, and want to test that, you should load the Rack app created from 73 | the `config.ru` file: 74 | 75 | ```ruby 76 | OUTER_APP = Rack::Builder.parse_file("config.ru").first 77 | 78 | class TestApp < Test::Unit::TestCase 79 | include Rack::Test::Methods 80 | 81 | def app 82 | OUTER_APP 83 | end 84 | 85 | def test_root 86 | get "/" 87 | assert last_response.ok? 88 | end 89 | end 90 | ``` 91 | 92 | If your application does not automatically use the `Rack::Lint` middleware in test mode, 93 | and you want to test that requests to and responses from your application are compliant 94 | with the Rack specification, you should manually use the `Rack::Lint` middleware: 95 | 96 | ```ruby 97 | class TestApp < Test::Unit::TestCase 98 | include Rack::Test::Methods 99 | 100 | APP = Rack::Lint.new(YOUR_APP) 101 | 102 | def app 103 | APP 104 | end 105 | end 106 | ``` 107 | 108 | ## Install 109 | 110 | To install the latest release as a gem: 111 | 112 | ``` 113 | gem install rack-test 114 | ``` 115 | 116 | Or add to your `Gemfile`: 117 | 118 | ``` 119 | gem 'rack-test' 120 | ``` 121 | 122 | ## Contribution 123 | 124 | Contributions are welcome. Please make sure to: 125 | 126 | * Use a regular forking workflow 127 | * Write tests for the new or changed behaviour 128 | * Provide an explanation/motivation in your commit message / PR message 129 | * Ensure `History.md` is updated 130 | 131 | ## Authors 132 | 133 | - Contributions from Bryan Helmkamp, Jeremy Evans, Simon Rozet, and others 134 | - Much of the original code was extracted from Merb 1.0's request helper 135 | 136 | ## License 137 | 138 | `rack-test` is released under the [MIT License](MIT-LICENSE.txt). 139 | 140 | ## Supported platforms 141 | 142 | * Ruby 2.0+ 143 | * JRuby 9.1+ 144 | 145 | ## Releasing 146 | 147 | * Bump VERSION in lib/rack/test/version.rb 148 | * Ensure `History.md` is up-to-date, including correct version and date 149 | * `git commit . -m 'Release $VERSION'` 150 | * `git push` 151 | * `git tag -a -m 'Tag the $VERSION release' $VERSION` 152 | * `git push --tags` 153 | * `gem build rack-test.gemspec` 154 | * `gem push rack-test-$VERSION.gem` 155 | * Add a discussion post for the release 156 | -------------------------------------------------------------------------------- /lib/rack/test/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | module Test 5 | module Utils # :nodoc: 6 | include Rack::Utils 7 | extend self 8 | 9 | class << self 10 | attr_accessor :override_build_nested_query 11 | end 12 | self.override_build_nested_query = false 13 | 14 | # Build a query string for the given value and prefix. The value 15 | # can be an array or hash of parameters. 16 | def build_nested_query(value, prefix = nil) 17 | return super if Rack::Test::Utils.override_build_nested_query 18 | 19 | case value 20 | when Array 21 | if value.empty? 22 | "#{prefix}[]=" 23 | else 24 | prefix += "[]" unless unescape(prefix).end_with?('[]') 25 | value.map do |v| 26 | build_nested_query(v, prefix.to_s) 27 | end.join('&') 28 | end 29 | when Hash 30 | value.map do |k, v| 31 | build_nested_query(v, prefix ? "#{prefix}[#{escape(k)}]" : escape(k)) 32 | end.join('&') 33 | when NilClass 34 | prefix.to_s 35 | else 36 | "#{prefix}=#{escape(value)}" 37 | end 38 | end 39 | 40 | # Build a multipart body for the given params. 41 | def build_multipart(params, _first = true, multipart = false) 42 | raise ArgumentError, 'value must be a Hash' unless params.is_a?(Hash) 43 | 44 | unless multipart 45 | query = lambda { |value| 46 | case value 47 | when Array 48 | value.each(&query) 49 | when Hash 50 | value.values.each(&query) 51 | when UploadedFile 52 | multipart = true 53 | end 54 | } 55 | params.values.each(&query) 56 | return nil unless multipart 57 | end 58 | 59 | params = normalize_multipart_params(params, true) 60 | 61 | buffer = String.new 62 | build_parts(buffer, params) 63 | buffer 64 | end 65 | 66 | private 67 | 68 | # Return a flattened hash of parameter values based on the given params. 69 | def normalize_multipart_params(params, first=false) 70 | flattened_params = {} 71 | 72 | params.each do |key, value| 73 | k = first ? key.to_s : "[#{key}]" 74 | 75 | case value 76 | when Array 77 | value.map do |v| 78 | if v.is_a?(Hash) 79 | nested_params = {} 80 | normalize_multipart_params(v).each do |subkey, subvalue| 81 | nested_params[subkey] = subvalue 82 | end 83 | (flattened_params["#{k}[]"] ||= []) << nested_params 84 | else 85 | flattened_params["#{k}[]"] = value 86 | end 87 | end 88 | when Hash 89 | normalize_multipart_params(value).each do |subkey, subvalue| 90 | flattened_params[k + subkey] = subvalue 91 | end 92 | else 93 | flattened_params[k] = value 94 | end 95 | end 96 | 97 | flattened_params 98 | end 99 | 100 | # Build the multipart content for uploading. 101 | def build_parts(buffer, parameters) 102 | _build_parts(buffer, parameters) 103 | buffer << END_BOUNDARY 104 | end 105 | 106 | # Append each multipart parameter value to the buffer. 107 | def _build_parts(buffer, parameters) 108 | parameters.map do |name, value| 109 | if name =~ /\[\]\Z/ && value.is_a?(Array) && value.all? { |v| v.is_a?(Hash) } 110 | value.each do |hash| 111 | new_value = {} 112 | hash.each { |k, v| new_value[name + k] = v } 113 | _build_parts(buffer, new_value) 114 | end 115 | else 116 | [value].flatten.map do |v| 117 | if v.respond_to?(:original_filename) 118 | build_file_part(buffer, name, v) 119 | else 120 | build_primitive_part(buffer, name, v) 121 | end 122 | end 123 | end 124 | end 125 | end 126 | 127 | # Append the multipart fragment for a parameter that isn't a file upload to the buffer. 128 | def build_primitive_part(buffer, parameter_name, value) 129 | buffer << 130 | START_BOUNDARY << 131 | "content-disposition: form-data; name=\"" << 132 | parameter_name.to_s.b << 133 | "\"\r\n\r\n" << 134 | value.to_s.b << 135 | "\r\n" 136 | buffer 137 | end 138 | 139 | # Append the multipart fragment for a parameter that is a file upload to the buffer. 140 | def build_file_part(buffer, parameter_name, uploaded_file) 141 | buffer << 142 | START_BOUNDARY << 143 | "content-disposition: form-data; name=\"" << 144 | parameter_name.to_s.b << 145 | "\"; filename=\"" << 146 | escape_path(uploaded_file.original_filename).b << 147 | "\"\r\ncontent-type: " << 148 | uploaded_file.content_type.to_s.b << 149 | "\r\ncontent-length: " << 150 | uploaded_file.size.to_s.b << 151 | "\r\n\r\n" 152 | 153 | # Handle old versions of Capybara::RackTest::Form::NilUploadedFile 154 | if uploaded_file.respond_to?(:set_encoding) 155 | uploaded_file.set_encoding(Encoding::BINARY) 156 | uploaded_file.append_to(buffer) 157 | end 158 | 159 | buffer << "\r\n" 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/fixtures/fake_app.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | require 'rack/lint' 3 | 4 | module Rack 5 | module Test 6 | class FakeApp 7 | def call(env) 8 | _, h, b = res = handle(env) 9 | length = 0 10 | b.each{|s| length += s.bytesize} 11 | h['content-length'] = length.to_s 12 | h['content-type'] = 'text/html;charset=utf-8' 13 | res 14 | end 15 | 16 | private 17 | 18 | def new_cookie_count(req) 19 | old_value = req.cookies['count'].to_i || 0 20 | (old_value + 1).to_s 21 | end 22 | 23 | def handle(env) 24 | method = env['REQUEST_METHOD'] 25 | path = env['PATH_INFO'] 26 | req = Rack::Request.new(env) 27 | params = req.params 28 | session = env['rack.session'] 29 | 30 | if path == '/' 31 | case method 32 | when 'HEAD', 'OPTIONS' 33 | return [200, {}, []] 34 | else 35 | return [200, {}, ["Hello, #{method}: #{params.inspect}"]] 36 | end 37 | end 38 | 39 | if path == '/redirect' && method == 'GET' 40 | return [301, { 'location' => '/redirected' }, []] 41 | end 42 | 43 | if path == '/nested/redirect' && method == 'GET' 44 | return [301, { 'location' => 'redirected' }, []] 45 | end 46 | 47 | if path == '/nested/redirected' && method == 'GET' 48 | return [200, {}, ['Hello World!']] 49 | end 50 | 51 | if path == '/absolute/redirect' && method == 'GET' 52 | return [301, { 'location' => 'https://www.google.com' }, []] 53 | end 54 | 55 | if path == '/redirect' && method == 'POST' 56 | if params['status'] 57 | return [Integer(params['status']), { 'location' => '/redirected' }, []] 58 | else 59 | return [302, { 'location' => '/redirected' }, []] 60 | end 61 | end 62 | 63 | if path == '/redirect-with-cookie' && method == 'GET' 64 | return [302, { 'set-cookie' => "value=1; path=/cookies;", 'location' => '/cookies/show' }, []] 65 | end 66 | 67 | if path == '/redirected' 68 | additional_info = if method == 'GET' 69 | ", session #{session.inspect} with options #{env['rack.session.options'].inspect}" 70 | else 71 | " using #{method.downcase} with #{params}" 72 | end 73 | return [200, {}, ["You've been redirected" + additional_info]] 74 | end 75 | 76 | if path == '/void' && method == 'GET' 77 | return [200, {}, []] 78 | end 79 | 80 | if %w[/cookies/show /COOKIES/show /not-cookies/show /cookies/default-path /cookies/default-path/sub].include?(path) && method == 'GET' 81 | return [200, {}, [req.cookies.inspect]] 82 | end 83 | 84 | if path == '/cookies/set-secure' && method == 'GET' 85 | return [200, { 'set-cookie' => "secure-cookie=#{params['value'] || raise}; secure" }, ['Set']] 86 | end 87 | 88 | if (path == '/cookies/set-simple' && method == 'GET') || (path == '/cookies/default-path' && method == 'POST') 89 | return [200, { 'set-cookie' => "simple=#{params['value'] || raise};" }, ['Set']] 90 | end 91 | 92 | if path == '/cookies/delete' && method == 'GET' 93 | return [200, { 'set-cookie' => "value=; expires=#{Time.at(0).httpdate}" }, []] 94 | end 95 | 96 | if path == '/cookies/count' && method == 'GET' 97 | new_value = new_cookie_count(req) 98 | return [200, { 'set-cookie' => "count=#{new_value};" }, [new_value]] 99 | end 100 | 101 | if path == '/cookies/set' && method == 'GET' 102 | return [200, { 'set-cookie' => "value=#{params['value'] || raise}; path=/cookies; expires=#{(Time.now+10).httpdate}" }, ['Set']] 103 | end 104 | 105 | if path == '/cookies/domain' && method == 'GET' 106 | new_value = new_cookie_count(req) 107 | return [200, { 'set-cookie' => "count=#{new_value}; domain=localhost.com" }, [new_value]] 108 | end 109 | 110 | if path == '/cookies/subdomain' && method == 'GET' 111 | new_value = new_cookie_count(req) 112 | return [200, { 'set-cookie' => "count=#{new_value}; domain=.example.org" }, [new_value]] 113 | end 114 | 115 | if path == '/cookies/set-uppercase' && method == 'GET' 116 | return [200, { 'set-cookie' => "VALUE=#{params['value'] || raise}; path=/cookies; expires=#{(Time.now+10).httpdate}" }, ['Set']] 117 | end 118 | 119 | if path == '/cookies/set-multiple' && method == 'GET' 120 | value = Rack.release >= '2.3' ? ["key1=value1", "key2=value2"] : "key1=value1\nkey2=value2" 121 | 122 | return [200, { 'set-cookie' => value }, ['Set']] 123 | end 124 | 125 | [404, {}, []] 126 | end 127 | end 128 | 129 | class InputRewinder 130 | def initialize(app) 131 | @app = app 132 | end 133 | 134 | def call(env) 135 | # Rack 3 removes the requirement for rewindable input. 136 | # Rack::Lint wraps the input and disallows direct access to rewind. 137 | # This breaks a lot of the specs that access last_request and 138 | # try to read the input. Work around this by reassigning the input 139 | # in env after the request, and rewinding it. 140 | input = env['rack.input'] 141 | @app.call(env) 142 | ensure 143 | if input 144 | input.rewind 145 | env['rack.input'] = input 146 | env.delete('rack.request.form_hash') 147 | end 148 | end 149 | end 150 | 151 | FAKE_APP = InputRewinder.new(Rack::Lint.new(FakeApp.new.freeze)) 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/rack/test/multipart_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | 5 | uploaded_files = Module.new do 6 | def fixture_path(name) 7 | File.join(File.dirname(__FILE__), '..', '..', 'fixtures', name) 8 | end 9 | 10 | def first_test_file_path 11 | fixture_path('foo.txt') 12 | end 13 | 14 | def uploaded_file 15 | Rack::Test::UploadedFile.new(first_test_file_path) 16 | end 17 | end 18 | 19 | describe 'Rack::Test::Session uploading one file' do 20 | include uploaded_files 21 | 22 | it 'sends the multipart/form-data content type if no content type is specified' do 23 | post '/', 'photo' => uploaded_file 24 | last_request.env['CONTENT_TYPE'].must_include 'multipart/form-data;' 25 | end 26 | 27 | it 'sends multipart/related content type if it is explicitly specified' do 28 | post '/', { 'photo' => uploaded_file }, 'CONTENT_TYPE' => 'multipart/related' 29 | last_request.env['CONTENT_TYPE'].must_include 'multipart/related;' 30 | end 31 | 32 | it 'sends regular params' do 33 | post '/', 'photo' => uploaded_file, 'foo' => 'bar' 34 | last_request.POST['foo'].must_equal 'bar' 35 | end 36 | 37 | it 'sends nested params' do 38 | post '/', 'photo' => uploaded_file, 'foo' => { 'bar' => 'baz' } 39 | last_request.POST['foo']['bar'].must_equal 'baz' 40 | end 41 | 42 | it 'sends multiple nested params' do 43 | post '/', 'photo' => uploaded_file, 'foo' => { 'bar' => { 'baz' => 'bop' } } 44 | last_request.POST['foo']['bar']['baz'].must_equal 'bop' 45 | end 46 | 47 | it 'sends params with arrays' do 48 | post '/', 'photo' => uploaded_file, 'foo' => %w[1 2] 49 | last_request.POST['foo'].must_equal %w[1 2] 50 | end 51 | 52 | it 'sends params with encoding sensitive values' do 53 | post '/', 'photo' => uploaded_file, 'foo' => 'bar? baz' 54 | last_request.POST['foo'].must_equal 'bar? baz' 55 | end 56 | 57 | it 'sends params encoded as ISO-8859-1' do 58 | utf8 = "\u2603" 59 | post '/', 'photo' => uploaded_file, 'foo' => 'bar', 'utf8' => utf8 60 | last_request.POST['foo'].must_equal 'bar' 61 | 62 | expected_value = if Rack::Test.encoding_aware_strings? 63 | utf8 64 | else 65 | utf8.b 66 | end 67 | 68 | last_request.POST['utf8'].must_equal expected_value 69 | end 70 | 71 | it 'sends params with parens in names' do 72 | post '/', 'photo' => uploaded_file, 'foo(1i)' => 'bar' 73 | last_request.POST['foo(1i)'].must_equal 'bar' 74 | end 75 | 76 | it 'sends params with encoding sensitive names' do 77 | post '/', 'photo' => uploaded_file, 'foo bar' => 'baz' 78 | last_request.POST['foo bar'].must_equal 'baz' 79 | end 80 | 81 | it 'sends files with the filename' do 82 | post '/', 'photo' => uploaded_file 83 | last_request.POST['photo'][:filename].must_equal 'foo.txt' 84 | end 85 | 86 | it 'sends files with the text/plain MIME type by default' do 87 | post '/', 'photo' => uploaded_file 88 | last_request.POST['photo'][:type].must_equal 'text/plain' 89 | end 90 | 91 | it 'sends files with the right name' do 92 | post '/', 'photo' => uploaded_file 93 | last_request.POST['photo'][:name].must_equal 'photo' 94 | end 95 | 96 | it 'allows overriding the content type' do 97 | post '/', 'photo' => Rack::Test::UploadedFile.new(first_test_file_path, 'image/jpeg') 98 | last_request.POST['photo'][:type].must_equal 'image/jpeg' 99 | end 100 | 101 | it 'sends files with a content-length in the header' do 102 | post '/', 'photo' => uploaded_file 103 | last_request.POST['photo'][:head].must_include 'content-length: 4' 104 | end 105 | 106 | it 'sends files as Tempfiles' do 107 | post '/', 'photo' => uploaded_file 108 | last_request.POST['photo'][:tempfile].class.must_equal Tempfile 109 | end 110 | 111 | it 'escapes spaces in filenames properly' do 112 | post '/', 'photo' => Rack::Test::UploadedFile.new(fixture_path('space case.txt')) 113 | last_request.POST['photo'][:filename].must_equal 'space case.txt' 114 | end 115 | end 116 | 117 | describe 'uploading two files' do 118 | include uploaded_files 119 | 120 | def second_test_file_path 121 | fixture_path('bar.txt') 122 | end 123 | 124 | def second_uploaded_file 125 | Rack::Test::UploadedFile.new(second_test_file_path) 126 | end 127 | 128 | it 'sends the multipart/form-data content type' do 129 | post '/', 'photos' => [uploaded_file, second_uploaded_file] 130 | last_request.env['CONTENT_TYPE'].must_include 'multipart/form-data;' 131 | end 132 | 133 | it 'sends files with the filename' do 134 | post '/', 'photos' => [uploaded_file, second_uploaded_file] 135 | last_request.POST['photos'].collect { |photo| photo[:filename] }.must_equal ['foo.txt', 'bar.txt'] 136 | end 137 | 138 | it 'sends files with the text/plain MIME type by default' do 139 | post '/', 'photos' => [uploaded_file, second_uploaded_file] 140 | last_request.POST['photos'].collect { |photo| photo[:type] }.must_equal ['text/plain', 'text/plain'] 141 | end 142 | 143 | it 'sends files with the right names' do 144 | post '/', 'photos' => [uploaded_file, second_uploaded_file] 145 | last_request.POST['photos'].all? { |photo| photo[:name].must_equal 'photos[]' } 146 | end 147 | 148 | it 'allows mixed content types' do 149 | image_file = Rack::Test::UploadedFile.new(first_test_file_path, 'image/jpeg') 150 | 151 | post '/', 'photos' => [uploaded_file, image_file] 152 | last_request.POST['photos'].collect { |photo| photo[:type] }.must_equal ['text/plain', 'image/jpeg'] 153 | end 154 | 155 | it 'sends files with a content-length in the header' do 156 | post '/', 'photos' => [uploaded_file, second_uploaded_file] 157 | last_request.POST['photos'].all? { |photo| photo[:head].must_include 'content-length: 4' } 158 | end 159 | 160 | it 'sends both files as Tempfiles' do 161 | post '/', 'photos' => [uploaded_file, second_uploaded_file] 162 | last_request.POST['photos'].all? { |photo| photo[:tempfile].class.must_equal Tempfile } 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/rack/test/cookie_jar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | require 'time' 5 | 6 | module Rack 7 | module Test 8 | # Represents individual cookies in the cookie jar. This is considered private 9 | # API and behavior of this class can change at any time. 10 | class Cookie # :nodoc: 11 | include Rack::Utils 12 | 13 | # The name of the cookie, will be a string 14 | attr_reader :name 15 | 16 | # The value of the cookie, will be a string or nil if there is no value. 17 | attr_reader :value 18 | 19 | # The raw string for the cookie, without options. Will generally be in 20 | # name=value format is name and value are provided. 21 | attr_reader :raw 22 | 23 | def initialize(raw, uri = nil, default_host = DEFAULT_HOST) 24 | @default_host = default_host 25 | uri ||= default_uri 26 | 27 | # separate the name / value pair from the cookie options 28 | @raw, options = raw.split(/[;,] */n, 2) 29 | 30 | @name, @value = parse_query(@raw, ';').to_a.first 31 | @options = Hash[parse_query(options, ';').map { |k, v| [k.downcase, v] }] 32 | 33 | if domain = @options['domain'] 34 | @exact_domain_match = false 35 | domain[0] = '' if domain[0] == '.' 36 | else 37 | # If the domain attribute is not present in the cookie, 38 | # the domain must match exactly. 39 | @exact_domain_match = true 40 | @options['domain'] = (uri.host || default_host) 41 | end 42 | 43 | # Set the path for the cookie to the directory containing 44 | # the request if it isn't set. 45 | @options['path'] ||= uri.path.sub(/\/[^\/]*\Z/, '') 46 | end 47 | 48 | # Whether the given cookie can replace the current cookie in the cookie jar. 49 | def replaces?(other) 50 | [name.downcase, domain, path] == [other.name.downcase, other.domain, other.path] 51 | end 52 | 53 | # Whether the cookie has a value. 54 | def empty? 55 | @value.nil? || @value.empty? 56 | end 57 | 58 | # The explicit or implicit domain for the cookie. 59 | def domain 60 | @options['domain'] 61 | end 62 | 63 | # Whether the cookie has the secure flag, indicating it can only be sent over 64 | # an encrypted connection. 65 | def secure? 66 | @options.key?('secure') 67 | end 68 | 69 | # Whether the cookie has the httponly flag, indicating it is not available via 70 | # a javascript API. 71 | def http_only? 72 | @options.key?('httponly') 73 | end 74 | 75 | # The explicit or implicit path for the cookie. 76 | def path 77 | ([*@options['path']].first.split(',').first || '/').strip 78 | end 79 | 80 | # A Time value for when the cookie expires, if the expires option is set. 81 | def expires 82 | Time.parse(@options['expires']) if @options['expires'] 83 | end 84 | 85 | # Whether the cookie is currently expired. 86 | def expired? 87 | expires && expires < Time.now 88 | end 89 | 90 | # Whether the cookie is valid for the given URI. 91 | def valid?(uri) 92 | uri ||= default_uri 93 | 94 | uri.host = @default_host if uri.host.nil? 95 | 96 | !!((!secure? || (secure? && uri.scheme == 'https')) && 97 | uri.host =~ Regexp.new("#{'^' if @exact_domain_match}#{Regexp.escape(domain)}$", Regexp::IGNORECASE)) 98 | end 99 | 100 | # Cookies that do not match the URI will not be sent in requests to the URI. 101 | def matches?(uri) 102 | !expired? && valid?(uri) && uri.path.start_with?(path) 103 | end 104 | 105 | # Order cookies by name, path, and domain. 106 | def <=>(other) 107 | [name, path, domain.reverse] <=> [other.name, other.path, other.domain.reverse] 108 | end 109 | 110 | # A hash of cookie options, including the cookie value, but excluding the cookie name. 111 | def to_h 112 | hash = @options.merge( 113 | 'value' => @value, 114 | 'HttpOnly' => http_only?, 115 | 'secure' => secure? 116 | ) 117 | hash.delete('httponly') 118 | hash 119 | end 120 | alias to_hash to_h 121 | 122 | private 123 | 124 | # The default URI to use for the cookie, including just the host. 125 | def default_uri 126 | URI.parse('//' + @default_host + '/') 127 | end 128 | end 129 | 130 | # Represents all cookies for a session, handling adding and 131 | # removing cookies, and finding which cookies apply to a given 132 | # request. This is considered private API and behavior of this 133 | # class can change at any time. 134 | class CookieJar # :nodoc: 135 | DELIMITER = '; '.freeze 136 | 137 | def initialize(cookies = [], default_host = DEFAULT_HOST) 138 | @default_host = default_host 139 | @cookies = cookies.sort! 140 | end 141 | 142 | # Ensure the copy uses a distinct cookies array. 143 | def initialize_copy(other) 144 | super 145 | @cookies = @cookies.dup 146 | end 147 | 148 | # Return the value for first cookie with the given name, or nil 149 | # if no such cookie exists. 150 | def [](name) 151 | name = name.to_s 152 | @cookies.each do |cookie| 153 | return cookie.value if cookie.name == name 154 | end 155 | nil 156 | end 157 | 158 | # Set a cookie with the given name and value in the 159 | # cookie jar. 160 | def []=(name, value) 161 | merge("#{name}=#{Rack::Utils.escape(value)}") 162 | end 163 | 164 | # Return the first cookie with the given name, or nil if 165 | # no such cookie exists. 166 | def get_cookie(name) 167 | @cookies.each do |cookie| 168 | return cookie if cookie.name == name 169 | end 170 | nil 171 | end 172 | 173 | # Delete all cookies with the given name from the cookie jar. 174 | def delete(name) 175 | @cookies.reject! do |cookie| 176 | cookie.name == name 177 | end 178 | nil 179 | end 180 | 181 | # Add a string of raw cookie information to the cookie jar, 182 | # if the cookie is valid for the given URI. 183 | # Cookies should be separated with a newline. 184 | def merge(raw_cookies, uri = nil) 185 | return unless raw_cookies 186 | 187 | raw_cookies = raw_cookies.split("\n") if raw_cookies.is_a? String 188 | 189 | raw_cookies.each do |raw_cookie| 190 | next if raw_cookie.empty? 191 | cookie = Cookie.new(raw_cookie, uri, @default_host) 192 | self << cookie if cookie.valid?(uri) 193 | end 194 | end 195 | 196 | # Add a Cookie to the cookie jar. 197 | def <<(new_cookie) 198 | @cookies.reject! do |existing_cookie| 199 | new_cookie.replaces?(existing_cookie) 200 | end 201 | 202 | @cookies << new_cookie 203 | @cookies.sort! 204 | end 205 | 206 | # Return a raw cookie string for the cookie header to 207 | # use for the given URI. 208 | def for(uri) 209 | buf = String.new 210 | delimiter = nil 211 | 212 | each_cookie_for(uri) do |cookie| 213 | if delimiter 214 | buf << delimiter 215 | else 216 | delimiter = DELIMITER 217 | end 218 | buf << cookie.raw 219 | end 220 | 221 | buf 222 | end 223 | 224 | # Return a hash cookie names and cookie values for cookies in the jar. 225 | def to_hash 226 | cookies = {} 227 | 228 | @cookies.each do |cookie| 229 | cookies[cookie.name] = cookie.value 230 | end 231 | 232 | cookies 233 | end 234 | 235 | private 236 | 237 | # Yield each cookie that matches for the URI. 238 | # 239 | # The cookies are sorted by most specific first. So, we loop through 240 | # all the cookies in order and add it to a hash by cookie name if 241 | # the cookie can be sent to the current URI. It's added to the hash 242 | # so that when we are done, the cookies will be unique by name and 243 | # we'll have grabbed the most specific to the URI. 244 | def each_cookie_for(uri) 245 | @cookies.each do |cookie| 246 | yield cookie if !uri || cookie.matches?(uri) 247 | end 248 | end 249 | end 250 | end 251 | end 252 | -------------------------------------------------------------------------------- /spec/rack/test/cookie_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | 5 | describe "Rack::Test::Session" do 6 | it 'keeps a cookie jar' do 7 | get '/cookies/show' 8 | last_request.cookies.must_equal({}) 9 | 10 | get '/cookies/set', 'value' => '1' 11 | get '/cookies/show' 12 | last_request.cookies.must_equal 'value' => '1' 13 | end 14 | 15 | it "doesn't send expired cookies" do 16 | get '/cookies/set', 'value' => '1' 17 | cookie = rack_mock_session.cookie_jar.instance_variable_get(:@cookies).first 18 | def cookie.expired?; true end 19 | get '/cookies/show' 20 | last_request.cookies.must_equal({}) 21 | end 22 | 23 | it 'cookie path defaults to the directory of the document that was requested' do 24 | post '/cookies/default-path', 'value' => 'cookie' 25 | get '/cookies/default-path' 26 | last_request.cookies.must_equal 'simple' => 'cookie' 27 | get '/cookies/default-path/sub' 28 | last_request.cookies.must_equal 'simple' => 'cookie' 29 | get '/' 30 | last_request.cookies.must_equal({}) 31 | get '/COOKIES/show' 32 | last_request.cookies.must_equal({}) 33 | end 34 | 35 | it 'uses the first "path" when multiple paths are defined' do 36 | cookie_string = [ 37 | '/', 38 | 'csrf_id=ABC123', 39 | 'path=/, _github_ses=ABC123', 40 | 'path=/', 41 | 'expires=Wed, 01 Jan 2020 08:00:00 GMT', 42 | 'HttpOnly' 43 | ].join(Rack::Test::CookieJar::DELIMITER) 44 | cookie = Rack::Test::Cookie.new(cookie_string) 45 | cookie.path.must_equal '/' 46 | end 47 | 48 | it 'uses the single "path" when only one path is defined' do 49 | cookie_string = [ 50 | '/', 51 | 'csrf_id=ABC123', 52 | 'path=/cookie', 53 | 'HttpOnly' 54 | ].join(Rack::Test::CookieJar::DELIMITER) 55 | cookie = Rack::Test::Cookie.new(cookie_string) 56 | cookie.path.must_equal '/cookie' 57 | end 58 | 59 | it 'attribute names are case-insensitive' do 60 | cookie_string = [ 61 | '/', 62 | 'csrf_id=ABC123', 63 | 'Path=/cookie', 64 | 'Expires=Wed, 01 Jan 2020 08:00:00 GMT', 65 | 'HttpOnly', 66 | 'Secure', 67 | ].join(Rack::Test::CookieJar::DELIMITER) 68 | cookie = Rack::Test::Cookie.new(cookie_string) 69 | 70 | cookie.path.must_equal '/cookie' 71 | cookie.secure?.must_equal true 72 | cookie.http_only?.must_equal true 73 | cookie.expires.must_equal Time.parse('Wed, 01 Jan 2020 08:00:00 GMT') 74 | end 75 | 76 | it 'escapes cookie values' do 77 | jar = Rack::Test::CookieJar.new 78 | jar['value'] = 'foo;abc' 79 | # Looks like it is not escaping, but actually escapes and unescapes, 80 | # otherwise abc would be treated as an attribute and not part of the value. 81 | jar['value'].must_equal 'foo;abc' 82 | end 83 | 84 | it 'deletes cookies directly from the CookieJar' do 85 | jar = Rack::Test::CookieJar.new 86 | jar['abcd'] = '1234' 87 | jar['abcd'].must_equal '1234' 88 | jar.delete('abcd') 89 | jar['abcd'].must_be_nil 90 | end 91 | 92 | it 'allow symbol access' do 93 | jar = Rack::Test::CookieJar.new 94 | jar['value'] = 'foo;abc' 95 | jar[:value].must_equal 'foo;abc' 96 | end 97 | 98 | it "doesn't send cookies with the wrong domain" do 99 | get 'http://www.example.com/cookies/set', 'value' => '1' 100 | get 'http://www.other.example/cookies/show' 101 | last_request.cookies.must_equal({}) 102 | end 103 | 104 | it "doesn't send cookies with the wrong path" do 105 | get '/cookies/set', 'value' => '1' 106 | get '/not-cookies/show' 107 | last_request.cookies.must_equal({}) 108 | end 109 | 110 | it "persists cookies across requests that don't return any cookie headers" do 111 | get '/cookies/set', 'value' => '1' 112 | get '/void' 113 | get '/cookies/show' 114 | last_request.cookies.must_equal 'value' => '1' 115 | end 116 | 117 | it 'deletes cookies' do 118 | get '/cookies/set', 'value' => '1' 119 | get '/cookies/delete' 120 | get '/cookies/show' 121 | last_request.cookies.must_equal({}) 122 | end 123 | 124 | it 'respects cookie domains when no domain is explicitly set' do 125 | request('http://example.org/cookies/count').body.must_equal '1' 126 | request('http://www.example.org/cookies/count').body.must_equal '1' 127 | request('http://example.org/cookies/count').body.must_equal '2' 128 | request('http://www.example.org/cookies/count').body.must_equal '2' 129 | end 130 | 131 | it 'treats domains case insensitively' do 132 | get 'http://example.com/cookies/set', 'value' => '1' 133 | get 'http://EXAMPLE.COM/cookies/show' 134 | last_request.cookies.must_equal 'value' => '1' 135 | end 136 | 137 | it 'treats paths case sensitively' do 138 | get '/cookies/set', 'value' => '1' 139 | get '/COOKIES/show' 140 | last_request.cookies.must_equal({}) 141 | end 142 | 143 | it 'prefers more specific cookies' do 144 | get 'http://example.com/cookies/set', 'value' => 'domain' 145 | get 'http://sub.example.com/cookies/set', 'value' => 'sub' 146 | 147 | get 'http://sub.example.com/cookies/show' 148 | last_request.cookies.must_equal 'value' => 'sub' 149 | 150 | get 'http://example.com/cookies/show' 151 | last_request.cookies.must_equal 'value' => 'domain' 152 | end 153 | 154 | it 'treats cookie names case insensitively' do 155 | get '/cookies/set', 'value' => 'lowercase' 156 | get '/cookies/set-uppercase', 'value' => 'UPPERCASE' 157 | get '/cookies/show' 158 | last_request.cookies.must_equal 'VALUE' => 'UPPERCASE' 159 | end 160 | 161 | it 'defaults the domain to the request domain' do 162 | get 'http://example.com/cookies/set-simple', 'value' => 'cookie' 163 | get 'http://example.com/cookies/show' 164 | last_request.cookies.must_equal 'simple' => 'cookie' 165 | 166 | get 'http://other.example/cookies/show' 167 | last_request.cookies.must_equal({}) 168 | end 169 | 170 | it 'defaults the domain to the request path up to the last slash' do 171 | get '/cookies/set-simple', 'value' => '1' 172 | get '/not-cookies/show' 173 | last_request.cookies.must_equal({}) 174 | end 175 | 176 | it 'supports secure cookies' do 177 | get 'https://example.com/cookies/set-secure', 'value' => 'set' 178 | get 'http://example.com/cookies/show' 179 | last_request.cookies.must_equal({}) 180 | 181 | get 'https://example.com/cookies/show' 182 | last_request.cookies.must_equal('secure-cookie' => 'set') 183 | rack_mock_session.cookie_jar['secure-cookie'].must_equal 'set' 184 | end 185 | 186 | it 'supports secure cookies when enabling SSL via env' do 187 | get '//example.com/cookies/set-secure', { 'value' => 'set' }, 'HTTPS' => 'on' 188 | get '//example.com/cookies/show', nil, 'HTTPS' => 'off' 189 | last_request.cookies.must_equal({}) 190 | 191 | get '//example.com/cookies/show', nil, 'HTTPS' => 'on' 192 | last_request.cookies.must_equal('secure-cookie' => 'set') 193 | rack_mock_session.cookie_jar['secure-cookie'].must_equal 'set' 194 | end 195 | 196 | it 'keeps separate cookie jars for different domains' do 197 | get 'http://example.com/cookies/set', 'value' => 'example' 198 | get 'http://example.com/cookies/show' 199 | last_request.cookies.must_equal 'value' => 'example' 200 | 201 | get 'http://other.example/cookies/set', 'value' => 'other' 202 | get 'http://other.example/cookies/show' 203 | last_request.cookies.must_equal 'value' => 'other' 204 | 205 | get 'http://example.com/cookies/show' 206 | last_request.cookies.must_equal 'value' => 'example' 207 | end 208 | 209 | it 'keeps one cookie jar for domain and its subdomains' do 210 | get 'http://example.org/cookies/subdomain' 211 | get 'http://example.org/cookies/subdomain' 212 | last_request.cookies.must_equal 'count' => '1' 213 | 214 | get 'http://foo.example.org/cookies/subdomain' 215 | last_request.cookies.must_equal 'count' => '2' 216 | end 217 | 218 | it 'allows cookies to be cleared' do 219 | get '/cookies/set', 'value' => '1' 220 | clear_cookies 221 | get '/cookies/show' 222 | last_request.cookies.must_equal({}) 223 | end 224 | 225 | it 'allow cookies to be set' do 226 | set_cookie 'value=10' 227 | get '/cookies/show' 228 | last_request.cookies.must_equal 'value' => '10' 229 | end 230 | 231 | it 'allows an array of cookies to be set' do 232 | set_cookie ['value=10', 'foo=bar'] 233 | get '/cookies/show' 234 | last_request.cookies.must_equal 'value' => '10', 'foo' => 'bar' 235 | end 236 | 237 | it 'skips empty string cookies' do 238 | set_cookie "value=10\n\nfoo=bar" 239 | get '/cookies/show' 240 | last_request.cookies.must_equal 'value' => '10', 'foo' => 'bar' 241 | end 242 | 243 | it 'parses multiple cookies properly' do 244 | get '/cookies/set-multiple' 245 | get '/cookies/show' 246 | last_request.cookies.must_equal 'key1' => 'value1', 'key2' => 'value2' 247 | end 248 | 249 | it 'supports multiple sessions' do 250 | with_session(:first) do 251 | get '/cookies/set', 'value' => '1' 252 | get '/cookies/show' 253 | last_request.cookies.must_equal 'value' => '1' 254 | end 255 | 256 | with_session(:second) do 257 | get '/cookies/show' 258 | last_request.cookies.must_equal({}) 259 | end 260 | end 261 | 262 | it 'uses :default as the default session name' do 263 | get '/cookies/set', 'value' => '1' 264 | get '/cookies/show' 265 | last_request.cookies.must_equal 'value' => '1' 266 | 267 | with_session(:default) do 268 | get '/cookies/show' 269 | last_request.cookies.must_equal 'value' => '1' 270 | end 271 | end 272 | 273 | it 'accepts explicitly provided cookies' do 274 | request '/cookies/show', cookie: 'value=1' 275 | last_request.cookies.must_equal 'value' => '1' 276 | end 277 | 278 | it 'sets and subsequently sends cookies when redirecting to the path of the cookie' do 279 | get '/redirect-with-cookie' 280 | follow_redirect! 281 | last_request.cookies.must_equal 'value' => '1' 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /spec/rack/test/utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../../spec_helper' 4 | 5 | describe "Rack::Test::Utils#build_nested_query" do 6 | include Rack::Test::Utils 7 | 8 | it 'converts empty strings to =' do 9 | build_nested_query('').must_equal '=' 10 | end 11 | 12 | it 'converts nil to an empty string' do 13 | build_nested_query(nil).must_equal '' 14 | end 15 | 16 | it 'converts hashes with nil values' do 17 | build_nested_query(a: nil).must_equal 'a' 18 | end 19 | 20 | it 'converts hashes' do 21 | build_nested_query(a: 1).must_equal 'a=1' 22 | end 23 | 24 | it 'converts hashes with multiple keys' do 25 | hash = { a: 1, b: 2 } 26 | build_nested_query(hash).must_equal 'a=1&b=2' 27 | end 28 | 29 | it 'converts empty arrays' do 30 | build_nested_query(a: []).must_equal 'a[]=' 31 | end 32 | 33 | it 'converts arrays with one element' do 34 | build_nested_query(a: [1]).must_equal 'a[]=1' 35 | end 36 | 37 | it 'converts arrays with multiple elements' do 38 | build_nested_query(a: [1, 2]).must_equal 'a[]=1&a[]=2' 39 | end 40 | 41 | it "converts arrays with brackets '[]' in the name" do 42 | build_nested_query('a[]' => [1, 2]).must_equal 'a%5B%5D=1&a%5B%5D=2' 43 | end 44 | 45 | it 'converts nested hashes' do 46 | build_nested_query(a: { b: 1 }).must_equal 'a[b]=1' 47 | end 48 | 49 | it 'converts arrays nested in a hash' do 50 | build_nested_query(a: { b: [1, 2] }).must_equal 'a[b][]=1&a[b][]=2' 51 | end 52 | 53 | it 'converts arrays of hashes' do 54 | build_nested_query(a: [{ b: 2 }, { c: 3 }]).must_equal 'a[][b]=2&a[][c]=3' 55 | end 56 | 57 | it 'supports hash keys with empty arrays' do 58 | input = { collection: [] } 59 | build_nested_query(input).must_equal 'collection[]=' 60 | end 61 | 62 | it 'percent encodes brackets with override_build_nested_query' do 63 | original = Rack::Test::Utils.override_build_nested_query 64 | Rack::Test::Utils.override_build_nested_query = true 65 | begin 66 | query = if Gem::Version.new(Rack.release) < Gem::Version.new("3.1") 67 | 'a[b]=c' 68 | else 69 | 'a%5Bb%5D=c' 70 | end 71 | build_nested_query({"a" => { "b" => "c" }}).must_equal query 72 | ensure 73 | Rack::Test::Utils.override_build_nested_query = original 74 | end 75 | end 76 | end 77 | 78 | describe 'Rack::Test::Utils.build_multipart' do 79 | include Rack::Test::Utils 80 | 81 | it 'builds multipart bodies' do 82 | files = Rack::Test::UploadedFile.new(multipart_file('foo.txt')) 83 | data = Rack::Test::Utils.build_multipart('submit-name' => 'Larry', 'files' => files) 84 | 85 | options = { 86 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 87 | 'CONTENT_LENGTH' => data.length.to_s, 88 | :input => StringIO.new(data) 89 | } 90 | env = Rack::MockRequest.env_for('/', options) 91 | params = Rack::Multipart.parse_multipart(env) 92 | params['submit-name'].must_equal 'Larry' 93 | params['files'][:filename].must_equal 'foo.txt' 94 | files.pos.must_equal 0 95 | params['files'][:tempfile].read.must_equal files.read 96 | end 97 | 98 | it 'handles uploaded files not responding to set_encoding as empty' do 99 | # Capybara::RackTest::Form::NilUploadedFile 100 | c = Class.new(Rack::Test::UploadedFile) do 101 | def initialize 102 | @empty_file = Tempfile.new('nil_uploaded_file') 103 | @empty_file.close 104 | end 105 | 106 | def original_filename; ''; end 107 | def content_type; 'application/octet-stream'; end 108 | def path; @empty_file.path; end 109 | def size; 0; end 110 | def read; ''; end 111 | def respond_to?(m, *a) 112 | return false if m == :set_encoding 113 | super(m, *a) 114 | end 115 | end 116 | 117 | data = Rack::Test::Utils.build_multipart('submit-name' => 'Larry', 'files' => c.new) 118 | options = { 119 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 120 | 'CONTENT_LENGTH' => data.length.to_s, 121 | :input => StringIO.new(data) 122 | } 123 | env = Rack::MockRequest.env_for('/', options) 124 | params = Rack::Multipart.parse_multipart(env) 125 | params['submit-name'].must_equal 'Larry' 126 | params['files'].must_be_nil 127 | data.must_include 'content-disposition: form-data; name="files"; filename=""' 128 | data.must_include 'content-length: 0' 129 | end 130 | 131 | it 'builds multipart bodies from array of files' do 132 | files = [Rack::Test::UploadedFile.new(multipart_file('foo.txt')), Rack::Test::UploadedFile.new(multipart_file('bar.txt'))] 133 | data = Rack::Test::Utils.build_multipart('submit-name' => 'Larry', 'files' => files) 134 | 135 | options = { 136 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 137 | 'CONTENT_LENGTH' => data.length.to_s, 138 | :input => StringIO.new(data) 139 | } 140 | env = Rack::MockRequest.env_for('/', options) 141 | params = Rack::Multipart.parse_multipart(env) 142 | params['submit-name'].must_equal 'Larry' 143 | 144 | params['files'][0][:filename].must_equal 'foo.txt' 145 | params['files'][0][:tempfile].read.must_equal "bar\n" 146 | 147 | params['files'][1][:filename].must_equal 'bar.txt' 148 | params['files'][1][:tempfile].read.must_equal "baz\n" 149 | end 150 | 151 | it 'builds multipart bodies from mixed array of a file and a primitive' do 152 | files = [Rack::Test::UploadedFile.new(multipart_file('foo.txt')), 'baz'] 153 | data = Rack::Test::Utils.build_multipart('files' => files) 154 | 155 | options = { 156 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 157 | 'CONTENT_LENGTH' => data.length.to_s, 158 | :input => StringIO.new(data) 159 | } 160 | env = Rack::MockRequest.env_for('/', options) 161 | params = Rack::Multipart.parse_multipart(env) 162 | 163 | params['files'][0][:filename].must_equal 'foo.txt' 164 | params['files'][0][:tempfile].read.must_equal "bar\n" 165 | 166 | params['files'][1].must_equal 'baz' 167 | end 168 | 169 | it 'builds nested multipart bodies' do 170 | files = Rack::Test::UploadedFile.new(multipart_file('foo.txt')) 171 | data = Rack::Test::Utils.build_multipart('people' => [{ 'submit-name' => 'Larry', 'files' => files }], 'foo' => %w[1 2]) 172 | 173 | options = { 174 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 175 | 'CONTENT_LENGTH' => data.length.to_s, 176 | :input => StringIO.new(data) 177 | } 178 | env = Rack::MockRequest.env_for('/', options) 179 | params = Rack::Multipart.parse_multipart(env) 180 | params['people'][0]['submit-name'].must_equal 'Larry' 181 | params['people'][0]['files'][:filename].must_equal 'foo.txt' 182 | params['people'][0]['files'][:tempfile].read.must_equal "bar\n" 183 | params['foo'].must_equal %w[1 2] 184 | end 185 | 186 | it 'builds nested multipart bodies with UTF-8 data' do 187 | files = Rack::Test::UploadedFile.new(multipart_file('mb.txt')) 188 | data = Rack::Test::Utils.build_multipart('people' => [{ 'submit-name' => "\u1234", 'files' => files }], 'foo' => %w[1 2]) 189 | 190 | options = { 191 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 192 | 'CONTENT_LENGTH' => data.length.to_s, 193 | :input => StringIO.new(data) 194 | } 195 | env = Rack::MockRequest.env_for('/', options) 196 | params = Rack::Multipart.parse_multipart(env) 197 | params['people'][0]['submit-name'].b.must_equal "\u1234".b 198 | params['people'][0]['files'][:filename].must_equal 'mb.txt' 199 | params['people'][0]['files'][:tempfile].read.must_equal "\u2345".b 200 | params['foo'].must_equal %w[1 2] 201 | 202 | files = Rack::Test::UploadedFile.new(multipart_file('mb.txt')) 203 | data = Rack::Test::Utils.build_multipart('people' => [{ 'files' => files, 'submit-name' => "\u1234" }], 'foo' => %w[1 2]) 204 | 205 | options = { 206 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 207 | 'CONTENT_LENGTH' => data.length.to_s, 208 | :input => StringIO.new(data) 209 | } 210 | env = Rack::MockRequest.env_for('/', options) 211 | params = Rack::Multipart.parse_multipart(env) 212 | params['people'][0]['submit-name'].b.must_equal "\u1234".b 213 | params['people'][0]['files'][:filename].must_equal 'mb.txt' 214 | params['people'][0]['files'][:tempfile].read.must_equal "\u2345".b 215 | params['foo'].must_equal %w[1 2] 216 | end 217 | 218 | it 'builds nested multipart bodies with an array of hashes' do 219 | files = Rack::Test::UploadedFile.new(multipart_file('foo.txt')) 220 | data = Rack::Test::Utils.build_multipart('files' => files, 'foo' => [{ 'id' => '1', 'name' => 'Dave' }, { 'id' => '2', 'name' => 'Steve' }]) 221 | 222 | options = { 223 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 224 | 'CONTENT_LENGTH' => data.length.to_s, 225 | :input => StringIO.new(data) 226 | } 227 | env = Rack::MockRequest.env_for('/', options) 228 | params = Rack::Multipart.parse_multipart(env) 229 | params['files'][:filename].must_equal 'foo.txt' 230 | params['files'][:tempfile].read.must_equal "bar\n" 231 | params['foo'].must_equal [{ 'id' => '1', 'name' => 'Dave' }, { 'id' => '2', 'name' => 'Steve' }] 232 | end 233 | 234 | it 'builds nested multipart bodies with arbitrarily nested array of hashes' do 235 | files = Rack::Test::UploadedFile.new(multipart_file('foo.txt')) 236 | data = Rack::Test::Utils.build_multipart('files' => files, 'foo' => { 'bar' => [{ 'id' => '1', 'name' => 'Dave' }, 237 | { 'id' => '2', 'name' => 'Steve', 'qux' => [{ 'id' => '3', 'name' => 'mike' }, 238 | { 'id' => '4', 'name' => 'Joan' }] }] }) 239 | 240 | options = { 241 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 242 | 'CONTENT_LENGTH' => data.length.to_s, 243 | :input => StringIO.new(data) 244 | } 245 | env = Rack::MockRequest.env_for('/', options) 246 | params = Rack::Multipart.parse_multipart(env) 247 | params['files'][:filename].must_equal 'foo.txt' 248 | params['files'][:tempfile].read.must_equal "bar\n" 249 | params['foo'].must_equal 'bar' => [{ 'id' => '1', 'name' => 'Dave' }, 250 | { 'id' => '2', 'name' => 'Steve', 'qux' => [{ 'id' => '3', 'name' => 'mike' }, 251 | { 'id' => '4', 'name' => 'Joan' }] }] 252 | end 253 | 254 | it 'does not break with params that look nested, but are not' do 255 | files = Rack::Test::UploadedFile.new(multipart_file('foo.txt')) 256 | data = Rack::Test::Utils.build_multipart('foo[]' => '1', 'bar[]' => { 'qux' => '2' }, 'files[]' => files) 257 | 258 | options = { 259 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 260 | 'CONTENT_LENGTH' => data.length.to_s, 261 | :input => StringIO.new(data) 262 | } 263 | env = Rack::MockRequest.env_for('/', options) 264 | params = Rack::Multipart.parse_multipart(env) 265 | params['files'][0][:filename].must_equal 'foo.txt' 266 | params['files'][0][:tempfile].read.must_equal "bar\n" 267 | params['foo'][0].must_equal '1' 268 | params['bar'][0].must_equal 'qux' => '2' 269 | end 270 | 271 | it 'allows for nested files' do 272 | files = Rack::Test::UploadedFile.new(multipart_file('foo.txt')) 273 | data = Rack::Test::Utils.build_multipart('foo' => [{ 'id' => '1', 'data' => files }, 274 | { 'id' => '2', 'data' => %w[3 4] }]) 275 | 276 | options = { 277 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 278 | 'CONTENT_LENGTH' => data.length.to_s, 279 | :input => StringIO.new(data) 280 | } 281 | env = Rack::MockRequest.env_for('/', options) 282 | params = Rack::Multipart.parse_multipart(env) 283 | params['foo'][0]['id'].must_equal '1' 284 | params['foo'][0]['data'][:filename].must_equal 'foo.txt' 285 | params['foo'][0]['data'][:tempfile].read.must_equal "bar\n" 286 | params['foo'][1].must_equal 'id' => '2', 'data' => %w[3 4] 287 | end 288 | 289 | it 'returns nil if no UploadedFiles were used' do 290 | Rack::Test::Utils.build_multipart('people' => [{ 'submit-name' => 'Larry', 'files' => 'contents' }]).must_be_nil 291 | end 292 | 293 | it 'allows for forcing multipart uploads even without a file' do 294 | data = Rack::Test::Utils.build_multipart({'foo' => [{ 'id' => '2', 'data' => %w[3 4] }]}, true, true) 295 | 296 | options = { 297 | 'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Test::MULTIPART_BOUNDARY}", 298 | 'CONTENT_LENGTH' => data.length.to_s, 299 | :input => StringIO.new(data) 300 | } 301 | env = Rack::MockRequest.env_for('/', options) 302 | params = Rack::Multipart.parse_multipart(env) 303 | params['foo'][0].must_equal 'id' => '2', 'data' => %w[3 4] 304 | end 305 | 306 | it 'raises ArgumentErrors if params is not a Hash' do 307 | proc do 308 | Rack::Test::Utils.build_multipart('foo=bar') 309 | end.must_raise(ArgumentError, 'value must be a Hash') 310 | end 311 | 312 | def multipart_file(name) 313 | File.join(File.dirname(__FILE__), '..', '..', 'fixtures', name.to_s) 314 | end 315 | end 316 | -------------------------------------------------------------------------------- /lib/rack/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 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' 28 | 29 | module Rack 30 | module Test 31 | # The default host to use for requests, when a full URI is not 32 | # provided. 33 | DEFAULT_HOST = 'example.org'.freeze 34 | 35 | # The default multipart boundary to use for multipart request bodies 36 | MULTIPART_BOUNDARY = '----------XnJLe9ZIbbGUYtzPQJ16u1'.freeze 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 | 44 | # The common base class for exceptions raised by Rack::Test 45 | class Error < StandardError; end 46 | 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. 50 | # 51 | # Rack::Test::Session's methods are most often called through Rack::Test::Methods, 52 | # which will automatically build a session when it's first used. 53 | class Session 54 | extend Forwardable 55 | include Rack::Test::Utils 56 | 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 65 | 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. 73 | # 74 | # Note: Generally, you won't need to initialize a Rack::Test::Session directly. 75 | # Instead, you should include Rack::Test::Methods into your testing context. 76 | # (See README.rdoc for an example) 77 | # 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 107 | end 108 | 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 115 | end 116 | 117 | # Run a block after the each request completes. 118 | def after_request(&block) 119 | @after_request << block 120 | end 121 | 122 | # Replace the current cookie jar with an empty cookie jar. 123 | def clear_cookies 124 | @cookie_jar = CookieJar.new([], @default_host) 125 | end 126 | 127 | # Set a cookie in the current cookie jar. 128 | def set_cookie(cookie, uri = nil) 129 | cookie_jar.merge(cookie, uri) 130 | end 131 | 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 137 | end 138 | 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 144 | end 145 | 146 | # Issue a request to the Rack app for the given URI and optional Rack 147 | # environment. Example: 148 | # 149 | # request "/" 150 | def request(uri, env = {}, &block) 151 | uri = parse_uri(uri, env) 152 | env = env_for(uri, env) 153 | process_request(uri, env, &block) 154 | end 155 | 156 | # Issue a request using the given HTTP verb for the given URI, with optional 157 | # params and rack environment. Example: 158 | # 159 | # custom_request "LINK", "/" 160 | def custom_request(verb, uri, params = {}, env = {}, &block) 161 | uri = parse_uri(uri, env) 162 | env = env_for(uri, env.merge(method: verb.to_s.upcase, params: params)) 163 | process_request(uri, env, &block) 164 | end 165 | 166 | # Set a header to be included on all subsequent requests through the 167 | # session. Use a value of nil to remove a previously configured header. 168 | # 169 | # In accordance with the Rack spec, headers will be included in the Rack 170 | # environment hash in HTTP_USER_AGENT form. Example: 171 | # 172 | # header "user-agent", "Firefox" 173 | def header(name, value) 174 | name = name.upcase 175 | name.tr!('-', '_') 176 | name = "HTTP_#{name}" unless name == 'CONTENT_TYPE' || name == 'CONTENT_LENGTH' 177 | env(name, value) 178 | end 179 | 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: 183 | # 184 | # env "rack.session", {:csrf => 'token'} 185 | def env(name, value) 186 | if value.nil? 187 | @env.delete(name) 188 | else 189 | @env[name] = value 190 | end 191 | end 192 | 193 | # Set the username and password for HTTP Basic authorization, to be 194 | # included in subsequent requests in the HTTP_AUTHORIZATION header. 195 | # 196 | # Example: 197 | # basic_authorize "bryan", "secret" 198 | def basic_authorize(username, password) 199 | encoded_login = ["#{username}:#{password}"].pack('m0') 200 | header('Authorization', "Basic #{encoded_login}") 201 | end 202 | 203 | alias authorize basic_authorize 204 | 205 | # Rack::Test will not follow any redirects automatically. This method 206 | # will follow the redirect returned (including setting the Referer header 207 | # on the new request) in the last response. If the last response was not 208 | # a redirect, an error will be raised. 209 | def follow_redirect! 210 | unless last_response.redirect? 211 | raise Error, 'Last response was not a redirect. Cannot follow_redirect!' 212 | 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 221 | 222 | # Compute the next location by appending the location header with the 223 | # last request, as per https://tools.ietf.org/html/rfc7231#section-7.1.2 224 | # Adding two absolute locations returns the right-hand location 225 | next_location = URI.parse(last_request.url) + URI.parse(last_response['Location']) 226 | 227 | custom_request( 228 | request_method, 229 | next_location.to_s, 230 | params, 231 | 'HTTP_REFERER' => last_request.url, 232 | 'rack.session' => last_request.session, 233 | 'rack.session.options' => last_request.session_options 234 | ) 235 | end 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 | 256 | private 257 | 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) 267 | end 268 | end 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. 293 | def env_for(uri, env) 294 | env = DEFAULT_ENV.merge(@env).merge!(env) 295 | 296 | env['HTTP_HOST'] ||= [uri.host, (uri.port if uri.port != uri.default_port)].compact.join(':') 297 | env['HTTPS'] = 'on' if URI::HTTPS === uri 298 | env['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' if env[:xhr] 299 | env['REQUEST_METHOD'] ||= env[:method] ? env[:method].to_s.upcase : 'GET' 300 | 301 | params = env.delete(:params) 302 | query_array = [uri.query] 303 | 304 | if env['REQUEST_METHOD'] == 'GET' 305 | # Treat params as query params 306 | if params 307 | append_query_params(query_array, params) 308 | end 309 | elsif !env.key?(:input) 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/') 313 | 314 | if params.is_a?(Hash) 315 | if !params.empty? && data = build_multipart(params, false, multipart) 316 | env[:input] = data 317 | env['CONTENT_LENGTH'] ||= data.length.to_s 318 | env['CONTENT_TYPE'] = "#{multipart_content_type(env)}; boundary=#{MULTIPART_BOUNDARY}" 319 | else 320 | env[:input] = build_nested_query(params) 321 | end 322 | else 323 | env[:input] = params 324 | end 325 | end 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 | 334 | set_cookie(env.delete(:cookie), uri) if env.key?(:cookie) 335 | 336 | Rack::MockRequest.env_for(uri.to_s, env) 337 | end 338 | 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 344 | 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 350 | else 351 | 'multipart/form-data' 352 | end 353 | end 354 | 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 361 | 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 367 | 368 | yield @last_response if block_given? 369 | 370 | @last_response 371 | end 372 | end 373 | 374 | # Whether the version of rack in use handles encodings. 375 | def self.encoding_aware_strings? 376 | Rack.release >= '1.6' 377 | end 378 | end 379 | 380 | # For backwards compatibility with 1.1.0 and below 381 | MockSession = Test::Session 382 | end 383 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | ## 2.2.0 / 2024-12-23 2 | 3 | * Bug fixes: 4 | * `Rack::Test::Cookie` now parses cookie parameters using a 5 | case-insensitive approach (Guillaume Malette #349) 6 | 7 | * Minor enhancements: 8 | * Arrays of cookies containing a blank cookie are now handled 9 | correctly when processing responses. (Martin Emde #343) 10 | * `Rack::Test::UploadedFile` no longer uses a finalizer for named 11 | paths to close and unlink the created Tempfile. Tempfile itself 12 | uses a finalizer to close and unlink itself, so there is no 13 | reason for `Rack::Test::UploadedFile` to do so (Jeremy Evans #338) 14 | 15 | ## 2.1.0 / 2023-03-14 16 | 17 | * Breaking changes: 18 | * Digest authentication support, deprecated in 2.0.0, has been 19 | removed (Jeremy Evans #307) 20 | * requiring rack/mock_session, deprecated in 2.0.0, has been removed 21 | (Jeremy Evans #307) 22 | 23 | * Minor enhancements: 24 | * The `original_filename` for `Rack::Test::UploadedFile` can now be 25 | set even if the content of the file comes from a file path 26 | (Stuart Chinery #314) 27 | * Add `Rack::Test::Session#restore_state`, for executing a block 28 | and restoring current state (last request, last response, and 29 | cookies) after the block (Jeremy Evans #316) 30 | * Make `Rack::Test::Methods` support `default_host` method similar to 31 | `app`, which will set the default host used for requests to the app 32 | (Jeremy Evans #317 #318) 33 | * Allow responses to set cookie paths not matching the current 34 | request URI. Such cookies will only be sent for paths matching 35 | the cookie path (Chris Waters #322) 36 | * Ignore leading dot for cookie domains, per RFC 6265 (Stephen Crosby 37 | #329) 38 | * Avoid creating empty multipart body if params is empty in 39 | `Rack::Test::Session#env_for` (Ryunosuke Sato #331) 40 | 41 | ## 2.0.2 / 2022-06-28 42 | 43 | * Bug fixes: 44 | * Fix additional incompatible character encodings error when building 45 | uploaded bodies (Jeremy Evans #311) 46 | 47 | ## 2.0.1 / 2022-06-27 48 | 49 | * Bug fixes: 50 | * Fix incompatible character encodings error when building uploaded 51 | file bodies (Jeremy Evans #308 #309) 52 | 53 | ## 2.0.0 / 2022-06-24 54 | 55 | * Breaking changes: 56 | * Digest authentication support is now deprecated, as it relies on 57 | digest authentication support in rack, which has been deprecated 58 | (Jeremy Evans #294) 59 | * `Rack::Test::Utils.build_primitive_part` no longer handles array 60 | values (Jeremy Evans #292) 61 | * `Rack::Test::Utils` module methods other than `build_nested_query` 62 | and `build_multipart` are now private methods (Jeremy Evans #297) 63 | * `Rack::MockSession` has been combined into `Rack::Test::Session`, 64 | and remains as an alias to `Rack::Test::Session`, but to keep some 65 | backwards compatibility, `Rack::Test::Session.new` will accept a 66 | `Rack::Test::Session` instance and return it (Jeremy Evans #297) 67 | * Previously protected methods in `Rack::Test::Cookie{,Jar}` are now 68 | private methods (Jeremy Evans #297) 69 | * `Rack::Test::Methods` no longer defines `build_rack_mock_session`, 70 | but for backwards compatibility, `build_rack_test_session` will call 71 | `build_rack_mock_session` if it is defined (Jeremy Evans #297) 72 | * `Rack::Test::Methods::METHODS` is no longer defined 73 | (Jeremy Evans #297) 74 | * `Rack::Test::Methods#_current_session_names` has been removed 75 | (Jeremy Evans #297) 76 | * Headers used/accessed by rack-test are now lower case, for rack 3 77 | compliance (Jeremy Evans #295) 78 | * Frozen literal strings are now used internally, which may break 79 | code that mutates static strings returned by rack-test, if any 80 | (Jeremy Evans #304) 81 | 82 | * Minor enhancements: 83 | * rack-test now works with the rack main branch (what will be rack 3) 84 | (Jeremy Evans #280 #292) 85 | * rack-test only loads the parts of rack it uses when running on the 86 | rack main branch (what will be rack 3) (Jeremy Evans #292) 87 | * Development dependencies have been significantly reduced, and are 88 | now a subset of the development dependencies of rack itself 89 | (Jeremy Evans #292) 90 | * Avoid creating multiple large copies of uploaded file data in 91 | memory (Jeremy Evans #286) 92 | * Specify HTTP/1.0 when submitting requests, to avoid responses with 93 | Transfer-Encoding: chunked (Jeremy Evans #288) 94 | * Support `:query_params` in rack environment for parameters that 95 | are appended to the query string instead of used in the request 96 | body (Jeremy Evans #150 #287) 97 | * Reduce required ruby version to 2.0, since tests run fine on 98 | Ruby 2.0 (Jeremy Evans #292) 99 | * Support :multipart env key for request methods to force multipart 100 | input (Jeremy Evans #303) 101 | * Force multipart input for request methods if content type starts 102 | with multipart (Jeremy Evans #303) 103 | * Improve performance of Utils.build_multipart by using an 104 | append-only design (Jeremy Evans #304) 105 | * Improve performance of Utils.build_nested_query for array values 106 | (Jeremy Evans #304) 107 | 108 | * Bug fixes: 109 | * The `CONTENT_TYPE` of multipart requests is now respected, if it 110 | starts with `multipart/` (Tom Knig #238) 111 | * Work correctly with responses that respond to `to_a` but not 112 | `to_ary` (Sergio Faria #276) 113 | * Raise an ArgumentError instead of a TypeError when providing a 114 | StringIO without an original filename when creating an 115 | UploadedFile (Nuno Correia #279) 116 | * Allow combining both an UploadedFile and a plain string when 117 | building a multipart upload (Mitsuhiro Shibuya #278) 118 | * Fix the generation of filenames with spaces to use path 119 | escaping instead of regular escaping, since path unescaping is 120 | used to decode it (Muir Manders, Jeremy Evans #275 #284) 121 | * Rewind tempfile used for multipart uploads before it is 122 | submitted to the application 123 | (Jeremy Evans, Alexander Dervish #261 #268 #286) 124 | * Fix Rack::Test.encoding_aware_strings to be true only on rack 125 | 1.6+ (Jeremy Evans #292) 126 | * Make Rack::Test::CookieJar#valid? return true/false 127 | (Jeremy Evans #292) 128 | * Cookies without a domain attribute no longer are submitted to 129 | requests for subdomains of that domain, for RFC 6265 130 | compliance (Jeremy Evans #292) 131 | * Increase required rack version to 1.3, since tests fail on 132 | rack 1.2 and below (Jeremy Evans #293) 133 | 134 | ## 1.1.0 / 2018-07-21 135 | 136 | * Breaking changes: 137 | * None 138 | 139 | * Minor enhancements / new functionality: 140 | * [GitHub] Added configuration for Stale (Per Lundberg #232) 141 | * follow_direct: Include rack.session.options (Mark Edmondson #233) 142 | * [CI] Add simplecov (fatkodima #227) 143 | 144 | * Bug fixes: 145 | * Follow relative locations correctly. (Samuel Williams #230) 146 | 147 | ## 1.0.0 / 2018-03-27 148 | 149 | * Breaking changes: 150 | * Always set CONTENT_TYPE for non-GET requests 151 | (Per Lundberg #223) 152 | 153 | * Minor enhancements / bug fixes: 154 | * Create tempfile using the basename without extension 155 | (Edouard Chin #201) 156 | * Save `session` during `follow_redirect!` 157 | (Alexander Popov #218) 158 | * Document how to use URL params with DELETE method 159 | (Timur Platonov #220) 160 | 161 | ## 0.8.3 / 2018-02-27 162 | 163 | * Bug fixes: 164 | * Do not set Content-Type if params are explicitly set to nil 165 | (Bartek Bułat #212). Fixes #200. 166 | * Fix `UploadedFile#new` regression 167 | (Per Lundberg #215) 168 | 169 | * Minor enhancements 170 | * [CI] Test against Ruby 2.5 (Nicolas Leger #217) 171 | 172 | ## 0.8.2 / 2017-11-21 173 | 174 | * Bug fixes: 175 | * Bugfix for `UploadedFile.new` unintended API breakage. 176 | (Per Lundberg #210) 177 | 178 | ## 0.8.0 / 2017-11-20 179 | 180 | * Known Issue 181 | * In `UploadedFile.new`, when passing e.g. a `Pathname` object, 182 | errors can be raised (eg. `ArgumentError: Missing original_filename 183 | for IO`, or `NoMethodError: undefined method 'size'`) See #207, #209. 184 | * Minor enhancements 185 | * Add a required_ruby_version of >= 2.2.2, similar to rack 2.0.1. 186 | (Samuel Giddins #194) 187 | * Remove new line from basic auth. (Felix Kleinschmidt #185) 188 | * Rubocop fixes (Per Lundberg #196) 189 | * Add how to install rack-test from github to README. (Jun Aruga #189) 190 | * Update CodeClimate badges (Toshimaru #195) 191 | * Add the ability to create Test::UploadedFile instances without 192 | the file system (Adam Milligan #149) 193 | * Add custom_request, remove duplication (Johannes Barre #184) 194 | * README.md: Added note about how to post JSON (Per Lundberg #198) 195 | * README.md: Added version badge (Per Lundberg #199) 196 | * Bug fixes 197 | * Bugfix for Cookies with multiple paths (Kyle Welsby #197) 198 | 199 | ## 0.7.0 / 2017-07-10 200 | 201 | * Major enhancements 202 | * The project URL changed to https://github.com/rack-test/rack-test 203 | (Per Lundberg, Dennis Sivia, Jun Aruga) 204 | * Rack 2 compatible. (Trevor Wennblom #81, Vít Ondruch, Jun Aruga #151) 205 | * Minor enhancements 206 | * Port to RSpec 3. (Murahashi [Matt] Kenichi #70, Antonio Terceiro #134) 207 | * Add Travis CI (Johannes Barre #108, Jun Aruga #161) 208 | * Don't append an ampersand when params are empty (sbilharz, #157) 209 | * Allow symbol access to cookies (Anorlondo448 #156) 210 | * README: Added Travis badge (Olivier Lacan, Per Lundberg #146) 211 | * `Rack::Test::Utils#build_multipart`: Allow passing a third parameter 212 | to force multipart (Koen Punt #142) 213 | * Allow better testing of cookies (Stephen Best #133) 214 | * make `build_multipart` work without mixing in `Rack::Test::Utils` 215 | (Aaron Patterson #131) 216 | * Add license to gemspec (Jordi Massaguer Pla #72, Anatol Pomozov #89, 217 | Anatol Pomozov #90, Johannes Barre #109, Mandaryn #115, 218 | Chris Marshall #120, Robert Reiz #126, Nic Benders #127, Nic Benders #130) 219 | * Feature/bulk pr for readme updates (Patrick Mulder #65, 220 | Troels Knak-Nielsen #74, Jeff Casimir #76) 221 | * Switch README format to Markdown (Dennis Sivia #176) 222 | * Convert History.txt to Markdown (Dennis Sivia #179) 223 | * Stop generating gemspec file. (Jun Aruga #181) 224 | * Fix errors at rake docs and whitespace. (Jun Aruga #183) 225 | * Ensure Rack::Test::UploadedFile closes its tempfile file descriptor 226 | on GC (Michael de Silva #180) 227 | * Change codeclimate URL correctly. (Jun Aruga #186) 228 | * Bug fixes 229 | * Initialize digest_username before using it. (Guo Xiang Tan #116, 230 | John Drago #124, Mike Perham #154) 231 | * Do not set Content-Type for DELETE requests (David Celis #132) 232 | * Adds support for empty arrays in params. (Cedric Röck, Tim Masliuchenko 233 | #125) 234 | * Update README code example quotes to be consistent. (Dmitry Gritsay #112) 235 | * Update README not to recommend installing gem with sudo. (T.J. Schuck #87) 236 | * Set scheme when using ENV to enable SSL (Neil Ang #155) 237 | * Reuse request method and parameters on HTTP 307 redirect. (Martin Mauch 238 | #138) 239 | 240 | ## 0.6.3 / 2015-01-09 241 | 242 | * Minor enhancements 243 | * Expose an env helper for persistently configuring the env as needed 244 | (Darío Javier Cravero #80) 245 | * Expose the tempfile of UploadedFile (Sytse Sijbrandij #67) 246 | * Bug fixes 247 | * Improve support for arrays of hashes in multipart forms (Murray Steele #69) 248 | * Improve test for query strings (Paul Grayson #66) 249 | 250 | ## 0.6.2 / 2012-09-27 251 | 252 | * Minor enhancements 253 | * Support HTTP PATCH method (Marjan Krekoten' #33) 254 | * Preserve the exact query string when possible (Paul Grayson #63) 255 | * Add a #delete method to CookieJar (Paul Grayson #63) 256 | * Bug fixes 257 | * Fix HTTP Digest authentication when the URI has query params 258 | * Don't append default ports to HTTP_HOST (David Lee #57) 259 | 260 | ## 0.6.1 / 2011-07-27 261 | 262 | * Bug fixes 263 | * Fix support for params with arrays in multipart forms (Joel Chippindale) 264 | * Add `respond_to?` to `Rack::Test::UploadedFile` to match `method_missing` (Josh Nichols) 265 | * Set the Referer header on requests issued by follow_redirect! (Ryan Bigg) 266 | 267 | ## 0.6.0 / 2011-05-03 268 | 269 | * Bug fixes 270 | * Add support for HTTP OPTIONS verb (Paolo "Nusco" Perrotta) 271 | * Call #finish on MockResponses if it's available (Aaron Patterson) 272 | * Allow HTTP_HOST to be set via #header (Geoff Buesing) 273 | 274 | ## 0.5.7 / 2011-01-01 275 | * Bug fixes 276 | * If no URI is present, include all cookies (Pratik Naik) 277 | 278 | ## 0.5.6 / 2010-09-25 279 | 280 | * Bug fixes 281 | * Use parse_nested_query for parsing URI like Rack does (Eugene Bolshakov) 282 | * Don't depend on ActiveSupport extension to String (Bryan Helmkamp) 283 | * Do not overwrite HTTP_HOST if it is set (Krekoten' Marjan) 284 | 285 | ## 0.5.5 / 2010-09-22 286 | 287 | * Bug fixes 288 | * Fix encoding of file uploads on Ruby 1.9 (Alan Kennedy) 289 | * Set env["HTTP_HOST"] when making requests (Istvan Hoka) 290 | 291 | ## 0.5.4 / 2010-05-26 292 | 293 | * Bug fixes 294 | * Don't stomp on Content-Type's supplied via #header (Bryan Helmkamp) 295 | * Fixed build_multipart to allow for arrays of files (Louis Rose) 296 | * Don't raise an error if raw cookies contain a blank line (John Reilly) 297 | * Handle parameter names with brackets properly (Tanner Donovan) 298 | 299 | ## 0.5.3 / 2009-11-27 300 | 301 | * Bug fixes 302 | * Fix cookie matching for subdomains (Marcin Kulik) 303 | 304 | ## 0.5.2 / 2009-11-13 305 | 306 | * Bug fixes 307 | * Call close on response body after iteration, not before (Simon Rozet) 308 | * Add missing require for time in cookie_jar.rb (Jerry West) 309 | 310 | ## 0.5.1 / 2009-10-27 311 | 312 | * Bug fixes 313 | * Escape cookie values (John Pignata) 314 | * Close the response body after each request, as per the Rack spec (Elomar França) 315 | 316 | ## 0.5.0 / 2009-09-19 317 | 318 | * Bug fixes 319 | * Set HTTP_X_REQUESTED_WITH in the Rack env when a request is made with :xhr => true (Ben Sales) 320 | * Set headers in the Rack env in HTTP_USER_AGENT form 321 | * Rack::Test now generates no Ruby warnings 322 | 323 | ## 0.4.2 / 2009-09-01 324 | 325 | * Minor enhancements 326 | * Merge in rack/master's build_multipart method which covers additional cases 327 | * Accept raw :params string input and merge it with the query string 328 | * Stringify and upcase request method (e.g. :post => "POST") (Josh Peek) 329 | * Bug fixes 330 | * Properly convert hashes with nil values (e.g. :foo => nil becomes simply "foo", not "foo=") 331 | * Prepend a slash to the URI path if it doesn't start with one (Josh Peek) 332 | * Requiring Rack-Test never modifies the Ruby load path anymore (Josh Peek) 333 | * Fixed using multiple cookies in a string on Ruby 1.8 (Tuomas Kareinen and Hermanni Hyytiälä) 334 | 335 | ## 0.4.1 / 2009-08-06 336 | 337 | * Minor enhancements 338 | * Support initializing a `Rack::Test::Session` with an app in addition to 339 | a `Rack::MockSession` 340 | * Allow CONTENT_TYPE to be specified in the env and not overwritten when 341 | sending a POST or PUT 342 | 343 | ## 0.4.0 / 2009-06-25 344 | 345 | * Minor enhancements 346 | * Expose hook for building `Rack::MockSessions` for frameworks that need 347 | to configure them before use 348 | * Support passing in arrays of raw cookies in addition to a newline 349 | separated string 350 | * Support after_request callbacks in MockSession for things like running 351 | background jobs 352 | * Allow multiple named sessions using with_session 353 | * Initialize `Rack::Test::Sessions` with `Rack::MockSessions` instead of apps. 354 | This change should help integration with other Ruby web frameworks 355 | (like Merb). 356 | * Support sending bodies for PUT requests (Larry Diehl) 357 | 358 | ## 0.3.0 / 2009-05-17 359 | 360 | * Major enhancements 361 | * Ruby 1.9 compatible (Simon Rozet, Michael Fellinger) 362 | * Minor enhancements 363 | * Add `CookieJar#[]` and `CookieJar#[]=` methods 364 | * Make the default host configurable 365 | * Use `Rack::Lint` and fix errors (Simon Rozet) 366 | * Extract `Rack::MockSession` from `Rack::Test::Session` to handle tracking 367 | the last request and response and the cookie jar 368 | * Add #set_cookie and #clear_cookies methods 369 | * Rename #authorize to #basic_authorize (#authorize remains as an alias) 370 | (Simon Rozet) 371 | 372 | ## 0.2.0 / 2009-04-26 373 | 374 | Because `#last_response` is now a `MockResponse` instead of a `Rack::Response`, `#last_response.body` 375 | now returns a string instead of an array. 376 | 377 | * Major enhancements 378 | * Support multipart requests via the UploadedFile class (thanks, Rails) 379 | * Minor enhancements 380 | * Updated for Rack 1.0 381 | * Don't require rubygems (See http://gist.github.com/54177) 382 | * Support HTTP Digest authentication with the `#digest_authorize` method 383 | * `#last_response` returns a `MockResponse` instead of a Response 384 | (Michael Fellinger) 385 | 386 | ## 0.1.0 / 2009-03-02 387 | 388 | * 1 major enhancement 389 | * Birthday! 390 | -------------------------------------------------------------------------------- /spec/rack/test_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require_relative '../spec_helper' 4 | 5 | describe 'Rack::Test::Session' do 6 | it 'has alias of Rack::MockSession for backwards compatibility' do 7 | Rack::MockSession.must_be_same_as Rack::Test::Session 8 | end 9 | 10 | it 'supports being initialized with a Rack::MockSession app' do 11 | Rack::Test::Session.new(Rack::MockSession.new(app)).request('/').must_be :ok? 12 | end 13 | 14 | it 'supports being initialized with an app' do 15 | Rack::Test::Session.new(app).request('/').must_be :ok? 16 | end 17 | end 18 | 19 | describe 'Rack::Test::Session#request' do 20 | it 'requests the URI using GET by default' do 21 | request '/' 22 | last_request.env['REQUEST_METHOD'].must_equal 'GET' 23 | last_response.must_be :ok? 24 | end 25 | 26 | it 'returns last response' do 27 | request('/').must_be :ok? 28 | end 29 | 30 | it 'uses the provided env' do 31 | request '/', 'X-Foo' => 'bar' 32 | last_request.env['X-Foo'].must_equal 'bar' 33 | end 34 | 35 | it 'allows HTTP_HOST to be set' do 36 | request '/', 'HTTP_HOST' => 'www.example.ua' 37 | last_request.env['HTTP_HOST'].must_equal 'www.example.ua' 38 | end 39 | 40 | it 'sets HTTP_HOST with port for non-default ports' do 41 | request 'http://foo.com:8080' 42 | last_request.env['HTTP_HOST'].must_equal 'foo.com:8080' 43 | request 'https://foo.com:8443' 44 | last_request.env['HTTP_HOST'].must_equal 'foo.com:8443' 45 | end 46 | 47 | it 'sets HTTP_HOST without port for default ports' do 48 | request 'http://foo.com' 49 | last_request.env['HTTP_HOST'].must_equal 'foo.com' 50 | request 'http://foo.com:80' 51 | last_request.env['HTTP_HOST'].must_equal 'foo.com' 52 | request 'https://foo.com:443' 53 | last_request.env['HTTP_HOST'].must_equal 'foo.com' 54 | end 55 | 56 | it 'defaults the REMOTE_ADDR to 127.0.0.1' do 57 | request '/' 58 | last_request.env['REMOTE_ADDR'].must_equal '127.0.0.1' 59 | end 60 | 61 | it 'sets rack.test to true in the env' do 62 | request '/' 63 | last_request.env['rack.test'].must_equal true 64 | end 65 | 66 | it 'defaults to port 80' do 67 | request '/' 68 | last_request.env['SERVER_PORT'].must_equal '80' 69 | end 70 | 71 | it 'defaults to example.org' do 72 | request '/' 73 | last_request.env['SERVER_NAME'].must_equal 'example.org' 74 | end 75 | 76 | it 'yields the response to a given block' do 77 | request '/' do |response| 78 | response.must_be :ok? 79 | end 80 | end 81 | 82 | it 'supports sending :params for GET' do 83 | request '/', params: { 'foo' => 'bar' } 84 | last_request.GET['foo'].must_equal 'bar' 85 | end 86 | 87 | it 'supports sending :query_params for GET' do 88 | request '/', query_params: { 'foo' => 'bar' } 89 | last_request.GET['foo'].must_equal 'bar' 90 | end 91 | 92 | it 'supports sending both :params and :query_params for GET' do 93 | request '/', query_params: { 'foo' => 'bar' }, params: { 'foo2' => 'bar2' } 94 | last_request.GET['foo'].must_equal 'bar' 95 | last_request.GET['foo2'].must_equal 'bar2' 96 | end 97 | 98 | it 'supports sending :params for POST' do 99 | request '/', method: :post, params: { 'foo' => 'bar' } 100 | last_request.POST['foo'].must_equal 'bar' 101 | end 102 | 103 | it 'does not use multipart input for :params for POST by default' do 104 | request '/', method: :post, params: { 'foo' => 'bar' } 105 | last_request.POST['foo'].must_equal 'bar' 106 | last_request.env['rack.input'].rewind 107 | last_request.env['rack.input'].read.must_equal 'foo=bar' 108 | end 109 | 110 | it 'supports :multipart when using :params for POST to force multipart input' do 111 | request '/', method: :post, params: { 'foo' => 'bar' }, multipart: true 112 | last_request.POST['foo'].must_equal 'bar' 113 | last_request.env['rack.input'].rewind 114 | last_request.env['rack.input'].read.must_include 'content-disposition: form-data; name="foo"' 115 | end 116 | 117 | it 'supports multipart CONTENT_TYPE when using :params for POST to force multipart input' do 118 | request '/', method: :post, params: { 'foo' => 'bar' }, 'CONTENT_TYPE'=>'multipart/form-data' 119 | last_request.POST['foo'].must_equal 'bar' 120 | last_request.env['rack.input'].rewind 121 | last_request.env['rack.input'].read.must_include 'content-disposition: form-data; name="foo"' 122 | end 123 | 124 | it 'supports multipart CONTENT_TYPE when using empty :params for POST to be empty body' do 125 | request '/', method: :post, params: {}, 'CONTENT_TYPE'=>'multipart/form-data' 126 | last_request.POST.must_be_empty 127 | last_request.env['rack.input'].rewind 128 | last_request.env['rack.input'].read.must_be_empty 129 | end 130 | 131 | it 'supports sending :query_params for POST' do 132 | request '/', method: :post, query_params: { 'foo' => 'bar' } 133 | last_request.GET['foo'].must_equal 'bar' 134 | end 135 | 136 | it 'supports sending both :params and :query_params for POST' do 137 | request '/', method: :post, query_params: { 'foo' => 'bar' }, params: { 'foo2' => 'bar2' } 138 | last_request.GET['foo'].must_equal 'bar' 139 | last_request.POST['foo2'].must_equal 'bar2' 140 | end 141 | 142 | it "doesn't follow redirects by default" do 143 | request '/redirect' 144 | last_response.must_be :redirect? 145 | last_response.body.must_be_empty 146 | end 147 | 148 | it 'allows passing :input in for POSTs' do 149 | request '/', method: :post, input: 'foo' 150 | last_request.env['rack.input'].read.must_equal 'foo' 151 | end 152 | 153 | it 'converts method names to a uppercase strings' do 154 | request '/', method: :put 155 | last_request.env['REQUEST_METHOD'].must_equal 'PUT' 156 | end 157 | 158 | it 'prepends a slash to the URI path' do 159 | request 'foo' 160 | last_request.env['PATH_INFO'].must_equal '/foo' 161 | end 162 | 163 | it 'accepts params and builds query strings for GET requests' do 164 | request '/foo?baz=2', params: { foo: { bar: '1' } } 165 | last_request.GET.must_equal 'baz' => '2', 'foo' => { 'bar' => '1' } 166 | end 167 | 168 | it 'parses query strings with repeated variable names correctly' do 169 | request '/foo?bar=2&bar=3' 170 | last_request.GET.must_equal 'bar' => '3' 171 | end 172 | 173 | it 'accepts raw input in params for GET requests' do 174 | request '/foo?baz=2', params: 'foo[bar]=1' 175 | last_request.GET.must_equal 'baz' => '2', 'foo' => { 'bar' => '1' } 176 | end 177 | 178 | it 'does not rewrite a GET query string when :params is not supplied' do 179 | request '/foo?a=1&b=2&c=3&e=4&d=5+%20' 180 | last_request.query_string.must_equal 'a=1&b=2&c=3&e=4&d=5+%20' 181 | end 182 | 183 | it 'does not rewrite a GET query string when :params is empty' do 184 | request '/foo?a=1&b=2&c=3&e=4&d=5', params: {} 185 | last_request.query_string.must_equal 'a=1&b=2&c=3&e=4&d=5' 186 | end 187 | 188 | it 'does not overwrite multiple query string keys' do 189 | request '/foo?a=1&a=2', params: { bar: 1 } 190 | last_request.query_string.must_equal 'a=1&a=2&bar=1' 191 | end 192 | 193 | it 'accepts params and builds url encoded params for POST requests' do 194 | request '/foo', method: :post, params: { foo: { bar: '1' } } 195 | last_request.env['rack.input'].read.must_equal 'foo[bar]=1' 196 | end 197 | 198 | it 'accepts raw input in params for POST requests' do 199 | request '/foo', method: :post, params: 'foo[bar]=1' 200 | last_request.env['rack.input'].read.must_equal 'foo[bar]=1' 201 | end 202 | 203 | it 'supports a Rack::Response' do 204 | app = lambda do |_env| 205 | Rack::Response.new('', 200, {}) 206 | end 207 | 208 | session = Rack::Test::Session.new(Rack::MockSession.new(app)) 209 | session.request('/').must_be :ok? 210 | end 211 | 212 | closeable_body = Class.new do 213 | def initialize 214 | @closed = false 215 | end 216 | 217 | def each 218 | return if @closed 219 | yield 'Hello, World!' 220 | end 221 | 222 | def close 223 | @closed = true 224 | end 225 | 226 | def closed? 227 | @closed 228 | end 229 | end 230 | 231 | it "closes response's body when body responds_to?(:close)" do 232 | body = closeable_body.new 233 | 234 | app = lambda do |_env| 235 | [200, { 'content-type' => 'text/html', 'content-length' => '13' }, body] 236 | end 237 | 238 | session = Rack::Test::Session.new(Rack::MockSession.new(app)) 239 | body.closed?.must_equal false 240 | session.request('/') 241 | body.closed?.must_equal true 242 | end 243 | 244 | it "closes response's body after iteration when body responds_to?(:close)" do 245 | body = nil 246 | app = lambda do |_env| 247 | [200, { 'content-type' => 'text/html', 'content-length' => '13' }, body = closeable_body.new] 248 | end 249 | 250 | session = Rack::Test::Session.new(Rack::MockSession.new(app)) 251 | session.request('/') 252 | session.last_response.body.must_equal 'Hello, World!' 253 | body.closed?.must_equal true 254 | end 255 | 256 | it 'sends the input when input is given' do 257 | request '/', method: 'POST', input: 'foo' 258 | last_request.env['rack.input'].read.must_equal 'foo' 259 | end 260 | 261 | it 'does not send a multipart request when input is given' do 262 | request '/', method: 'POST', input: 'foo' 263 | last_request.env['CONTENT_TYPE'].wont_equal 'application/x-www-form-urlencoded' 264 | end 265 | 266 | it 'uses application/x-www-form-urlencoded as the CONTENT_TYPE for a POST specified with :method' do 267 | request '/', method: 'POST' 268 | last_request.env['CONTENT_TYPE'].must_equal 'application/x-www-form-urlencoded' 269 | end 270 | 271 | it 'uses application/x-www-form-urlencoded as the CONTENT_TYPE for a POST specified with REQUEST_METHOD' do 272 | request '/', 'REQUEST_METHOD' => 'POST' 273 | last_request.env['CONTENT_TYPE'].must_equal 'application/x-www-form-urlencoded' 274 | end 275 | 276 | it 'does not overwrite the CONTENT_TYPE when CONTENT_TYPE is specified in the env' do 277 | request '/', 'CONTENT_TYPE' => 'application/xml' 278 | last_request.env['CONTENT_TYPE'].must_equal 'application/xml' 279 | end 280 | 281 | it 'sets rack.url_scheme to https when the URL is https://' do 282 | request 'https://example.org/' 283 | last_request.env['rack.url_scheme'].must_equal 'https' 284 | end 285 | 286 | it 'sets SERVER_PORT to 443 when the URL is https://' do 287 | request 'https://example.org/' 288 | last_request.env['SERVER_PORT'].must_equal '443' 289 | end 290 | 291 | it 'sets HTTPS to on when the URL is https://' do 292 | request 'https://example.org/' 293 | last_request.env['HTTPS'].must_equal 'on' 294 | end 295 | 296 | it 'sends XMLHttpRequest for the X-Requested-With header if :xhr option is given' do 297 | request '/', xhr: true 298 | last_request.env['HTTP_X_REQUESTED_WITH'].must_equal 'XMLHttpRequest' 299 | last_request.must_be :xhr? 300 | end 301 | end 302 | 303 | describe 'Rack::Test::Session#header' do 304 | it 'sets a header to be sent with requests' do 305 | header 'User-Agent', 'Firefox' 306 | request '/' 307 | 308 | last_request.env['HTTP_USER_AGENT'].must_equal 'Firefox' 309 | end 310 | 311 | it 'sets a content-type to be sent with requests' do 312 | header 'content-type', 'application/json' 313 | request '/' 314 | 315 | last_request.env['CONTENT_TYPE'].must_equal 'application/json' 316 | end 317 | 318 | it 'sets a Host to be sent with requests' do 319 | header 'Host', 'www.example.ua' 320 | request '/' 321 | 322 | last_request.env['HTTP_HOST'].must_equal 'www.example.ua' 323 | end 324 | 325 | it 'persists across multiple requests' do 326 | header 'User-Agent', 'Firefox' 327 | request '/' 328 | request '/' 329 | 330 | last_request.env['HTTP_USER_AGENT'].must_equal 'Firefox' 331 | end 332 | 333 | it 'overwrites previously set headers' do 334 | header 'User-Agent', 'Firefox' 335 | header 'User-Agent', 'Safari' 336 | request '/' 337 | 338 | last_request.env['HTTP_USER_AGENT'].must_equal 'Safari' 339 | end 340 | 341 | it 'can be used to clear a header' do 342 | header 'User-Agent', 'Firefox' 343 | header 'User-Agent', nil 344 | request '/' 345 | 346 | last_request.env.wont_include 'HTTP_USER_AGENT' 347 | end 348 | 349 | it 'is overridden by headers sent during the request' do 350 | header 'User-Agent', 'Firefox' 351 | request '/', 'HTTP_USER_AGENT' => 'Safari' 352 | 353 | last_request.env['HTTP_USER_AGENT'].must_equal 'Safari' 354 | end 355 | end 356 | 357 | describe 'Rack::Test::Session#env' do 358 | it 'sets the env to be sent with requests' do 359 | env 'rack.session', csrf: 'token' 360 | request '/' 361 | 362 | last_request.env['rack.session'].must_equal csrf: 'token' 363 | end 364 | 365 | it 'persists across multiple requests' do 366 | env 'rack.session', csrf: 'token' 367 | request '/' 368 | request '/' 369 | 370 | last_request.env['rack.session'].must_equal csrf: 'token' 371 | end 372 | 373 | it 'overwrites previously set envs' do 374 | env 'rack.session', csrf: 'token' 375 | env 'rack.session', some: :thing 376 | request '/' 377 | 378 | last_request.env['rack.session'].must_equal some: :thing 379 | end 380 | 381 | it 'can be used to clear a env' do 382 | env 'rack.session', csrf: 'token' 383 | env 'rack.session', nil 384 | request '/' 385 | 386 | last_request.env.wont_include 'X_CSRF_TOKEN' 387 | end 388 | 389 | it 'is overridden by envs sent during the request' do 390 | env 'rack.session', csrf: 'token' 391 | request '/', 'rack.session' => { some: :thing } 392 | 393 | last_request.env['rack.session'].must_equal some: :thing 394 | end 395 | end 396 | 397 | describe 'Rack::Test::Session#basic_authorize' do 398 | it 'sets the HTTP_AUTHORIZATION header' do 399 | basic_authorize 'bryan', 'secret' 400 | request '/' 401 | 402 | last_request.env['HTTP_AUTHORIZATION'].must_equal 'Basic YnJ5YW46c2VjcmV0' 403 | end 404 | 405 | it 'includes the header for subsequent requests' do 406 | basic_authorize 'bryan', 'secret' 407 | request '/' 408 | request '/' 409 | 410 | last_request.env['HTTP_AUTHORIZATION'].must_equal 'Basic YnJ5YW46c2VjcmV0' 411 | end 412 | end 413 | 414 | describe 'Rack::Test::Session#follow_redirect!' do 415 | it 'follows redirects' do 416 | get '/redirect' 417 | follow_redirect! 418 | 419 | last_response.wont_be :redirect? 420 | last_response.body.must_equal "You've been redirected, session {} with options {}" 421 | last_request.env['HTTP_REFERER'].must_equal 'http://example.org/redirect' 422 | end 423 | 424 | it 'follows absolute redirects' do 425 | get '/absolute/redirect' 426 | last_response.headers['location'].must_equal 'https://www.google.com' 427 | follow_redirect! 428 | last_request.env['PATH_INFO'].must_equal '/' 429 | last_request.env['HTTP_HOST'].must_equal 'www.google.com' 430 | last_request.env['HTTPS'].must_equal 'on' 431 | end 432 | 433 | it 'follows nested redirects' do 434 | get '/nested/redirect' 435 | 436 | last_response.headers['location'].must_equal 'redirected' 437 | follow_redirect! 438 | 439 | last_response.must_be :ok? 440 | last_request.env['PATH_INFO'].must_equal '/nested/redirected' 441 | end 442 | 443 | it 'does not include params when following the redirect' do 444 | get '/redirect', 'foo' => 'bar' 445 | follow_redirect! 446 | 447 | last_request.GET.must_be_empty 448 | end 449 | 450 | it 'includes session when following the redirect' do 451 | get '/redirect', {}, 'rack.session' => { 'foo' => 'bar' } 452 | follow_redirect! 453 | 454 | last_response.body.must_match(/session \{"foo" ?=> ?"bar"\}/) 455 | end 456 | 457 | it 'includes session options when following the redirect' do 458 | get '/redirect', {}, 'rack.session.options' => { 'foo' => 'bar' } 459 | follow_redirect! 460 | 461 | last_response.body.must_match(/session \{\} with options \{"foo" ?=> ?"bar"\}/) 462 | end 463 | 464 | it 'raises an error if the last_response is not set' do 465 | proc do 466 | follow_redirect! 467 | end.must_raise(Rack::Test::Error) 468 | end 469 | 470 | it 'raises an error if the last_response is not a redirect' do 471 | get '/' 472 | 473 | proc do 474 | follow_redirect! 475 | end.must_raise(Rack::Test::Error) 476 | end 477 | 478 | it 'keeps the original method and params for HTTP 307' do 479 | post '/redirect?status=307', foo: 'bar' 480 | follow_redirect! 481 | last_response.body.must_include 'post' 482 | last_response.body.must_include 'foo' 483 | last_response.body.must_include 'bar' 484 | end 485 | end 486 | 487 | describe 'Rack::Test::Session#last_request' do 488 | it 'returns the most recent request' do 489 | request '/' 490 | last_request.env['PATH_INFO'].must_equal '/' 491 | end 492 | 493 | it 'raises an error if no requests have been issued' do 494 | proc do 495 | last_request 496 | end.must_raise(Rack::Test::Error) 497 | end 498 | end 499 | 500 | describe 'Rack::Test::Session#last_response' do 501 | it 'returns the most recent response' do 502 | request '/' 503 | last_response['content-type'].must_equal 'text/html;charset=utf-8' 504 | end 505 | 506 | it 'raises an error if no requests have been issued' do 507 | proc do 508 | last_response 509 | end.must_raise(Rack::Test::Error) 510 | end 511 | end 512 | 513 | describe 'Rack::Test::Session#after_request' do 514 | it 'runs callbacks after each request' do 515 | ran = false 516 | 517 | rack_mock_session.after_request do 518 | ran = true 519 | end 520 | 521 | get '/' 522 | ran.must_equal true 523 | end 524 | 525 | it 'runs multiple callbacks' do 526 | count = 0 527 | 528 | 2.times do 529 | rack_mock_session.after_request do 530 | count += 1 531 | end 532 | end 533 | 534 | get '/' 535 | count.must_equal 2 536 | end 537 | end 538 | 539 | verb_examples = Module.new do 540 | extend Minitest::Spec::DSL 541 | 542 | it 'requests the URL using VERB' do 543 | public_send(verb, '/') 544 | 545 | last_request.env['REQUEST_METHOD'].must_equal verb.to_s.upcase 546 | last_response.must_be :ok? 547 | end 548 | 549 | it 'uses the provided env' do 550 | public_send(verb, '/', {}, 'HTTP_USER_AGENT' => 'Rack::Test') 551 | last_request.env['HTTP_USER_AGENT'].must_equal 'Rack::Test' 552 | end 553 | 554 | it 'yields the response to a given block' do 555 | yielded = false 556 | 557 | public_send(verb, '/') do |response| 558 | response.must_be :ok? 559 | yielded = true 560 | end 561 | 562 | yielded.must_equal true 563 | end 564 | 565 | it 'sets the HTTP_HOST header with port' do 566 | public_send(verb, 'http://example.org:8080/uri') 567 | last_request.env['HTTP_HOST'].must_equal 'example.org:8080' 568 | end 569 | 570 | it 'sets the HTTP_HOST header without port' do 571 | public_send(verb, '/uri') 572 | last_request.env['HTTP_HOST'].must_equal 'example.org' 573 | end 574 | 575 | it 'sends XMLHttpRequest for the X-Requested-With header' do 576 | public_send(verb, '/', {}, xhr: true) 577 | last_request.env['HTTP_X_REQUESTED_WITH'].must_equal 'XMLHttpRequest' 578 | last_request.must_be :xhr? 579 | end 580 | end 581 | 582 | non_get_verb_examples = Module.new do 583 | extend Minitest::Spec::DSL 584 | 585 | it 'sets CONTENT_TYPE to application/x-www-form-urlencoded when params are not provided' do 586 | public_send(verb, '/') 587 | last_request.env['CONTENT_TYPE'].must_equal 'application/x-www-form-urlencoded' 588 | end 589 | 590 | it 'sets CONTENT_LENGTH to zero when params are not provided' do 591 | public_send(verb, '/') 592 | last_request.env['CONTENT_LENGTH'].must_equal '0' 593 | end 594 | 595 | it 'sets CONTENT_TYPE to application/x-www-form-urlencoded when params are explicitly set to nil' do 596 | public_send(verb, '/', nil) 597 | last_request.env['CONTENT_TYPE'].must_equal 'application/x-www-form-urlencoded' 598 | end 599 | 600 | it 'sets CONTENT_LENGTH to 0 when params are explicitly set to nil' do 601 | public_send(verb, '/', nil) 602 | last_request.env['CONTENT_LENGTH'].must_equal '0' 603 | end 604 | end 605 | 606 | describe 'Rack::Test::Session#get' do 607 | def verb; :get; end 608 | include verb_examples 609 | 610 | # This is not actually explicitly stated in the relevant RFCs; 611 | # https://tools.ietf.org/html/rfc7231#section-3.1.1.5 612 | # ...but e.g. curl do not set it for GET requests. 613 | it 'does not set CONTENT_TYPE when params are not provided' do 614 | get '/' 615 | last_request.env.wont_include 'CONTENT_TYPE' 616 | end 617 | 618 | it 'sets CONTENT_LENGTH to zero or does not set it when params are not provided' do 619 | get '/' 620 | ['0', nil].must_include last_request.env['CONTENT_LENGTH'] 621 | end 622 | 623 | it 'does not set CONTENT_TYPE twhen params are explicitly set to nil' do 624 | get '/', nil 625 | last_request.env.wont_include 'CONTENT_TYPE' 626 | end 627 | 628 | it 'sets CONTENT_LENGTH to zero or does not set it when params are explicitly set to nil' do 629 | get '/', nil 630 | ['0', nil].must_include last_request.env['CONTENT_LENGTH'] 631 | end 632 | 633 | it 'uses the provided params hash' do 634 | get '/', foo: 'bar' 635 | last_request.GET.must_equal 'foo' => 'bar' 636 | end 637 | 638 | it 'sends params with parens in names' do 639 | get '/', 'foo(1i)' => 'bar' 640 | last_request.GET['foo(1i)'].must_equal 'bar' 641 | end 642 | 643 | it 'supports params with encoding sensitive names' do 644 | get '/', 'foo bar' => 'baz' 645 | last_request.GET['foo bar'].must_equal 'baz' 646 | end 647 | 648 | it 'supports params with nested encoding sensitive names' do 649 | get '/', 'boo' => { 'foo bar' => 'baz' } 650 | last_request.GET.must_equal 'boo' => { 'foo bar' => 'baz' } 651 | end 652 | 653 | it 'accepts params in the path' do 654 | get '/?foo=bar' 655 | last_request.GET.must_equal 'foo' => 'bar' 656 | end 657 | end 658 | 659 | describe 'Rack::Test::Session#head' do 660 | def verb; :head; end 661 | include verb_examples 662 | include non_get_verb_examples 663 | end 664 | 665 | describe 'Rack::Test::Session#post' do 666 | def verb; :post; end 667 | include verb_examples 668 | include non_get_verb_examples 669 | 670 | it 'uses the provided params hash' do 671 | post '/', foo: 'bar' 672 | last_request.POST.must_equal 'foo' => 'bar' 673 | end 674 | 675 | it 'supports params with encoding sensitive names' do 676 | post '/', 'foo bar' => 'baz' 677 | last_request.POST['foo bar'].must_equal 'baz' 678 | end 679 | 680 | it 'uses application/x-www-form-urlencoded as the default CONTENT_TYPE' do 681 | post '/' 682 | last_request.env['CONTENT_TYPE'].must_equal 'application/x-www-form-urlencoded' 683 | end 684 | 685 | it 'sets the CONTENT_LENGTH' do 686 | post '/', foo: 'bar' 687 | last_request.env['CONTENT_LENGTH'].must_equal '7' 688 | end 689 | 690 | it 'accepts a body' do 691 | post '/', 'Lobsterlicious!' 692 | last_request.body.read.must_equal 'Lobsterlicious!' 693 | end 694 | 695 | it 'does not overwrite the CONTENT_TYPE when CONTENT_TYPE is specified in the env' do 696 | post '/', {}, 'CONTENT_TYPE' => 'application/xml' 697 | last_request.env['CONTENT_TYPE'].must_equal 'application/xml' 698 | end 699 | end 700 | 701 | describe 'Rack::Test::Session#put' do 702 | def verb; :put; end 703 | include verb_examples 704 | include non_get_verb_examples 705 | 706 | it 'accepts a body' do 707 | put '/', 'Lobsterlicious!' 708 | last_request.body.read.must_equal 'Lobsterlicious!' 709 | end 710 | end 711 | 712 | describe 'Rack::Test::Session#patch' do 713 | def verb; :patch; end 714 | include verb_examples 715 | include non_get_verb_examples 716 | 717 | it 'accepts a body' do 718 | patch '/', 'Lobsterlicious!' 719 | last_request.body.read.must_equal 'Lobsterlicious!' 720 | end 721 | end 722 | 723 | describe 'Rack::Test::Session#delete' do 724 | def verb; :delete; end 725 | include verb_examples 726 | include non_get_verb_examples 727 | 728 | it 'accepts a body' do 729 | patch '/', 'Lobsterlicious!' 730 | last_request.body.read.must_equal 'Lobsterlicious!' 731 | end 732 | 733 | it 'uses the provided params hash' do 734 | delete '/', foo: 'bar' 735 | last_request.GET.must_equal({}) 736 | last_request.POST.must_equal 'foo' => 'bar' 737 | last_request.body.rewind 738 | last_request.body.read.must_equal 'foo=bar' 739 | end 740 | 741 | it 'accepts params in the path' do 742 | delete '/?foo=bar' 743 | last_request.GET.must_equal 'foo' => 'bar' 744 | last_request.POST.must_equal({}) 745 | last_request.body.read.must_equal '' 746 | end 747 | 748 | it 'accepts a body' do 749 | delete '/', 'Lobsterlicious!' 750 | last_request.GET.must_equal({}) 751 | last_request.body.read.must_equal 'Lobsterlicious!' 752 | end 753 | end 754 | 755 | describe 'Rack::Test::Session#options' do 756 | def verb; :options; end 757 | include verb_examples 758 | include non_get_verb_examples 759 | end 760 | 761 | describe 'Rack::Test::Session#custom_request' do 762 | it 'requests the URL using the given' do 763 | custom_request('link', '/') 764 | 765 | last_request.env['REQUEST_METHOD'].must_equal 'LINK' 766 | last_response.must_be :ok? 767 | end 768 | 769 | it 'uses the provided env' do 770 | custom_request('link', '/', {}, 'HTTP_USER_AGENT' => 'Rack::Test') 771 | last_request.env['HTTP_USER_AGENT'].must_equal 'Rack::Test' 772 | end 773 | 774 | it 'yields the response to a given block' do 775 | yielded = false 776 | 777 | custom_request('link', '/') do |response| 778 | response.must_be :ok? 779 | yielded = true 780 | end 781 | 782 | yielded.must_equal true 783 | end 784 | 785 | it 'sets the HTTP_HOST header with port' do 786 | custom_request('link', 'http://example.org:8080/uri') 787 | last_request.env['HTTP_HOST'].must_equal 'example.org:8080' 788 | end 789 | 790 | it 'sets the HTTP_HOST header without port' do 791 | custom_request('link', '/uri') 792 | last_request.env['HTTP_HOST'].must_equal 'example.org' 793 | end 794 | 795 | it 'sends XMLHttpRequest for the X-Requested-With header for an XHR' do 796 | custom_request('link', '/', {}, xhr: true) 797 | last_request.env['HTTP_X_REQUESTED_WITH'].must_equal 'XMLHttpRequest' 798 | last_request.must_be :xhr? 799 | end 800 | end 801 | 802 | describe 'Rack::Test::Session#restore_state' do 803 | it 'restores last request, last response, cookies, and hooks after block' do 804 | after_request = [] 805 | current_session.after_request{after_request << 1} 806 | 807 | get('/') 808 | request = last_request 809 | response = last_response 810 | current_session.cookie_jar['simple'].must_be_nil 811 | after_request.must_equal [1] 812 | 813 | current_session.restore_state do 814 | current_session.after_request{after_request << 2} 815 | get('/cookies/set-simple?value=foo') 816 | current_session.cookie_jar['simple'].must_equal 'foo' 817 | 818 | last_request.wont_be_same_as request 819 | last_response.wont_be_same_as response 820 | after_request.must_equal [1, 1, 2] 821 | end 822 | 823 | last_request.must_be_same_as request 824 | last_response.must_be_same_as response 825 | current_session.cookie_jar['simple'].must_be_nil 826 | 827 | get('/') 828 | after_request.must_equal [1, 1, 2, 1] 829 | end 830 | end 831 | --------------------------------------------------------------------------------