DEV Community

Cover image for Testing signed and encrypted cookies in Rails
Phil Nash
Phil Nash

Posted on • Originally published at philna.sh on

Testing signed and encrypted cookies in Rails

Recently I’ve been refactoring the tests for a gem I maintain and I needed to test that it sets the right cookies at the right time. But the cookies in use in the gem are signed cookies and that caused a slight hiccup for me. I’d never tested the value in a signed cookie before and it wasn’t immediately obvious what to do.

So I thought I would share what I found out in case it helps.

Cookies on Rails

In Rails applications there are three flavours of cookies available: simple session cookies, signed cookies and encrypted cookies. You can set any of these by using the cookies object in a controller, like this:

class CookiesController < ApplicationController
  def index
    cookies["simple"] = "Hello, I am easy to read."
    cookies.signed["protected"] = "Hello, I can be read, but I can't be tampered with."
    cookies.encrypted["private"] = "Hello, I can't be read or tampered with."
  end
end
Enter fullscreen mode Exit fullscreen mode

Simple cookies

Simple cookies are made up of plain text. If you inspected the cookie above called “simple” in the browser you would see the text “Hello, I am easy to read.”

Simple cookies are ok for storing data that doesn’t really matter. The end user can read and change it and your application shouldn’t be affected.

Signed cookies

Signed cookies are not sent to the browser as plain text. Instead they comprise of a payload and signature separated by two dashes --. Before the dashes, the payload is base 64 encoded data. To read the data you can base 64 decode it. This data isn’t secret, but it can’t be tampered with because the second part of the cookie is a signature. The signature is created by taking an HMAC SHA1 digest of the application’s secret_key_base and the data in the cookie. If the contents of the cookie are changed when you try to read the cookie, the signature will no longer match the contents and Rails will return nil. Under the hood this is all handled by the ActiveSupport::MessageVerifier. As you can see above, you don’t need to worry about that, you can treat the cookies.signed object as if it were a hash.

Signed cookies are useful for data that can be read by the user, but you need to trust is the same when you get it back to the server again.

Encrypted cookies

Encrypted cookies take this one step further and encrypt the data in the cookie, then sign it. This is handled by the ActiveSupport::MessageEncryptor and means that without the secret_key_base you cannot read or write to this cookie. Thankfully there’s no need to worry about the encryption yourself, using the cookies.encrypted object you can set encrypted cookies as though they were a regular hash.

Encrypted cookies are useful for private data that you want to store with the user, but you don’t want them, or anyone, to read.

Testing cookies

Suppose we now want to test the controller we saw above. We want to ensure that all of our cookies are set correctly. The test might look something like this:

class CookiesControllerTest < ActionDispatch::IntegrationTest
  test "should set cookies when getting the index" do
    get root_url
    assert_response :success
    assert_equal "Hello, I am easy to read.", cookies["simple"]
    assert_equal "Hello, I can be read, but I can't be tampered with.", cookies["protected"]
    assert_equal "Hello, I can't be read or tampered with.", cookies["private"]
  end
end
Enter fullscreen mode Exit fullscreen mode

Or with RSpec Rails:

RSpec.describe CookiesController, type: :request do
  it "should set cookies when getting the index" do
    get root_url
    expect(response).to have_http_status(:success)
    expect(cookies["simple"]).to eq("Hello, I am easy to read.")
    expect(cookies["protected"]).to eq("Hello, I can be read, but I can't be tampered with.")
    expect(cookies["private"]).to eq("Hello, I can't be read or tampered with.")
  end
end
Enter fullscreen mode Exit fullscreen mode

But this would fail at the test for the signed cookie and wouldn’t pass for the encrypted cookie either. You can’t just call on those cookies straight out of the jar if they have been signed or encrypted.

You might think you should test against the signed and encrypted version of the cookies, like this:

    assert_equal "Hello, I can be read, but I can't be tampered with.", cookies.signed["protected"]
    assert_equal "Hello, I can't be read or tampered with.", cookies.encrypted["private"]
Enter fullscreen mode Exit fullscreen mode

That doesn’t work either. At least it doesn’t work if you are using the currently recommended way of testing controllers, with ActionDispatch::IntegrationTest in Minitest or type: :request in RSpec.

If you have the older style ActionController::TestCase or type: :controller tests, then cookies.signed and cookies.encrypted will work. If you have an application with the older style tests, do carry on reading just in case you decide to refactor them to come in line with the current Rails way.

With the tests above, the cookies object is actually an instance of Rack::Test::CookieJar, which does not have knowledge of your Rails application secrets.

So how do we test these cookies?

This is where I got to with the gem I was working on. I needed to test the result of a signed cookie, but I had a Rack::Test::CookieJar object. The good news is we can bring the Rails application’s own ActionDispatch::Cookies::CookieJar back into play to decode your signed cookies and decrypt your encrypted cookies.

To do so, you instantiate an instance of ActionDispatch::Cookies::CookieJar using the request object from the test and a hash of your cookie data. You can then call signed or encrypted on that cookie jar. So now the test looks like:

class CookiesControllerTest < ActionDispatch::IntegrationTest
  test "should set cookies when getting the index" do
    get root_url
    assert_response :success
    assert_equal "Hello, I am easy to read.", cookies["simple"]
    jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
    assert_equal "Hello, I can be read, but I can't be tampered with.", jar.signed["protected"]
    assert_equal "Hello, I can't be read or tampered with.", jar.encrypted["private"]
  end
end
Enter fullscreen mode Exit fullscreen mode

Or the spec would look like:

RSpec.describe CookiesController, type: :request do
  it "gets cookies from the response" do
    get root_url
    expect(response).to have_http_status(:success)
    expect(cookies["simple"]).to eq("Hello, I am easy to read.")
    jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
    expect(jar.signed["protected"]).to eq("Hello, I can be read, but I can't be tampered with.")
    expect(jar.encrypted["private"]).to eq("Hello, I can't be read or tampered with.")
  end
end
Enter fullscreen mode Exit fullscreen mode

Red, Green, Re-snack-tor

In this post we’ve seen how to test signed or encrypted cookies in Rails. Hopefully your test suite is running green and your cookies are covered now.

I’m going to get back to the refactor I was working on. There are plenty more tests to cover now that these cookies have been polished off.

Top comments (5)

Collapse
 
chaadow profile image
Chad Lee B.

Nice article and great tips. I would suggest switching the order of the "asserts" as the first argument is the "expected" value.

I'm using ActionDispatch::IntegrationTest( minitest) and I found that if I use cookies instead of response.cookies then it returns the "request cookies". Have you encountered the same behaviour.

Collapse
 
philnash profile image
Phil Nash

Ah, you're right. I've switched those asserts around.

I am mostly writing my tests in RSpec, with RSpec-Rails, at the moment. Their documentation recommends using cookies over response.cookies and that's worked for me so far.

While writing this up, it was confusing what the difference between cookies and response.cookies is within the minitest versions. I might have to dig into that further to work out exactly what is going on.

Collapse
 
chaadow profile image
Chad Lee B. • Edited

Update: I've figured out how to make it work in my tests ( was having weird issues but application related, not rails related)

Basically to make this work in Rails/minitest, one would do:

jar = ActionDispatch::Cookies::CookieJar.build(request, response.cookies)
Enter fullscreen mode Exit fullscreen mode

We would need to use response.cookies. No need to add .to_hash because response.cookies is already a hash.

Unlike Rspec, cookies in Minitest ( or Rails custom version of minitest) always refer to the request cookies which is an instance of Rack::Test::CookieJar

Thread Thread
 
bubbaspaarx profile image
Edward Sparks • Edited

This is the answer I have been looking for. I had originally used

jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash).signed
Enter fullscreen mode Exit fullscreen mode

But realised late on that the cookies.to_hash was empty as I never set cookies in Rack::Test::CookieJar

Thank you for this

Collapse
 
chaadow profile image
Chad Lee B.

Alright! I'm currently in the process of refactoring some tests using minitest (with Rails), will let you know what would potentially work for me (hopefully).

I've spelunked the rails code source, #cookies in minitest always refers to the request's cookies ( even after the HTTP call), and is a Rack::Test::CookieJar instance, whereas response.cookies is a plain ruby hash containing the responses's cookies. I'm going to instantiate a rails Cookie Jar as suggested in your article, but using response.cookies as the second argument.