Rspec 1.3.x cookie issue with Ruby 1.9.3 & Rails 2.3.x

At HouseTrip we use RSpec and cucumber for all our tests. While upgrading our app to Ruby 1.9.3 we hit a strange issue testing cookies in our controller specs.

In RSpec, you can fetch cookie values by using the cookies hash as cookies[:cookie_name] just like in the Rails controller, views and helpers. However, all our cookie related tests failed because the value of cookie_name was never being set. We found that we could still access the cookies using response.template.cookies meaning the cookies were being set correctly, but there was something not right with the tests.

response.template #=> ActionView::Base
  response.template.cookies #=> ActionController::CookieJar

Using response.template.cookies in specs presented three issues:

  • It did not look as natural as you access cookies in controllers or even in RSpec generally
  • While we changed cookies[:cookie_name] on our 1.9.3 branch, other developers could still be adding cookies[:cookie_name] tests on our 1.8 branch
  • An itch to find out the root cause of the issue

We poked into the Rails cookie tests code and it has a bunch of tests that uses the cookie Hash notation so our tests should have worked.

According to the tests present in CookieStoreTest, headers['Set-Cookie'] could be nil or a String

So we grepped/acked the rspec-Rails code for cookies.

  • First Suspect: CookiesProxy class does a require "action_controller/cookies" which basically provides the ability to set/access/delete cookies in RSpec, not very helpful but it did give us a clue.

  • Second Suspect: FunctionalExampleGroup class has an instance method called cookies that returns the CookiesProxy object. More importantly FunctionalExampleGroup inherits ActionController::TestCase and includes the ActionController::TestProcess module This has a method called cookies that actually returns a cookies hash with cookie name and values. In Rails, ActionController::TestProcess#cookies is implemented as:

def cookies
    cookies = {}
    Array(headers['Set-Cookie']).each do |cookie|
      key, value = cookie.split(";").first.split("=").map {|val| Rack::Utils.unescape(val)}
      cookies[key] = value
    end
    cookies
  end

It generates the cookie hash using Kernel#Array (mind you, this is not the array class) method to split the cookies from headers['Set-Cookie']

For the inquisitive, according to Ruby 1.8 doc:

----------------------------------------------------------- Kernel#Array
     Array(arg)    => array
  ------------------------------------------------------------------------
     Returns _arg_ as an +Array+. First tries to call _arg_+.to_ary+,
     then _arg_+.to_a+. If both fail, creates a single element array
     containing _arg_ (unless _arg_ is +nil+).

        Array(1..5)   #=> [1, 2, 3, 4, 5]

In Rails 2.3.x, various cookie information is stored in headers['Set-Cookie'] and is delimited by ā€œ\nā€ where each cookie is composed of several components like;

page_views=1;path=/;expires=Fri,10-May-2013 07:27:02 GMT

Here cookie name is page_views and its value is 1, with other information delimited by ;

So, it looked like the problem is somewhere in the above ActionController::TestProcess#cookies method. Hence, after some debugging exercise, we zeroed in on the Kernel#Array method.

In Ruby 1.8

Array("adf\nsadf")
  => ["adf\n", "sadf"]

and in Ruby 1.9+

Array("adf\nsadf")
  => ["adf\nsadf"]

Bingo, to illustrate:

Say in Ruby 1.8 the headers['Set-Cookie'] is set to page_views=1;path=/;expires=Fri,10-May-2013 07:27:02 GMT\nuser=john;path=/;expires=Fri,10-May-2013 07:27:02 GMT then doing;

>> Array("page_views=1;path=/;expires=Fri,10-May-2013 07:27:02 GMT\nuser=john;path=/;expires=Fri,10-May-2013 07:27:02 GMT")
  => ["page_views=1;path=/;expires=Fri,10-May-2013 07:27:02 GMT\n", "user=john;path=/;expires=Fri,10-May-2013 07:27:02 GMT"]

will change that into a two element array and rest of the ActionController::TestProcess#cookies code will iterate over it and return cookies hash with two keys i.e. page_views and user. With their respective values and discarding everything after the ; for each cookie.

However, in Ruby 1.9

Array("page_views=1;path=/;expires=Fri,10-May-2013 07:27:02 GMT\nuser=john;path=/;expires=Fri,10-May-2013 07:27:02 GMT")
  => ["page_views=1;path=/;expires=Fri,10-May-2013 07:27:02 GMT\nuser=john;path=/;expires=Fri,10-May-2013 07:27:02 GMT"]

This does not split the string based on \n and returns just a single element array. Hence, the ActionController::TestProcess#cookies method implementation will just set the first cookie, i.e. page_views value in the cookie hash and discard everything after the ;.

This whole exercise lead us to our simple solution, which was to slightly change the implementation of the method to;

...
  header_cookie_list = case headers['Set-Cookie']
  when String
    headers['Set-Cookie'].split("\n")
  when Array
    headers['Set-Cookie'].map{ |x| x.split("\n") }
  end
  Array(header_cookie_list.flatten).each do |cookie|
...

IMHO it is pointless to use obscure functionality on a method or object that is not obvious or does not make sense, i.e. Kernel#Array to split a string by \n. Neither the name nor the docs suggest anything like that. On the other hand, I am quite content that this has changed in Ruby 1.9+ and similar things like that e.g.

Ruby 1.8
  >> [1,2].to_s
  => "12" # WHY
 
  Ruby 1.9
  [1,2].to_s
  => "[1, 2]"

  Ruby 1.8
  "asdf".to_a
  => ["asdf"] # WHY
  
  Ruby 1.9
  "adf".to_a
  NoMethodError: undefined method `to_a' for "adf":String   # I am quite happy with this

that people end up misusing.

On another note, Strings are no longer Enumerable in 1.9+ (since they are now encoded). To iterate over a string you need to tell Ruby exactly what you want to iterate on i.e. String#lines, String#chars, String#bytes, etc. For example,

Ruby 1.9

 "adf".chars.to_a
 => ["a", "d", "f"] # which makes lot more sense (same as Ruby 1.8)

After a little rant, I also feel obliged to mention that I am thankful to all the people who put enormous amount of effort towards Ruby as well as Rails and contributing to make this better.

photo of Nasir

Developer
comments powered by Disqus