Rails has so much support for testing built into itself that its rare I come up with something that’s hard to test but HTTP headers is not easy.
Normally you don’t have to worry about HTTP headers as they’re set by the browser and you don’t do much with them.
Recently I was working on an application where each user has an IP whitelist and they are only allowed to come from their whitelisted IP addresses.
This isn’t as crazy as it sounds since the app is in a corporate environment and the users will all be coming from their corporate networks.
Basically this means our authentication method needs 3 pieces of information
username
password
remote ip address
What makes this interesting is that the first two are input by the user but the ip address comes from the browser and network.
Writing an RSpec unit test or Cucumber scenario to test user parameters (username and password) is something we’ve all done before
but today I’m going to talk about how you can also test the IP address in a header.
Implementation
Before we look at how to test this let’s take a look at the implementation of our SessionController.
app/controllers/sessions_controller.rb
These three actions provide login and logout.
new displays the login form with username & password fields
create uses the username and password from the form as well as the ip address to create a session (i.e. authenticate).
In case the request hops through some proxy servers we use the X-Forwarded-For header
to get the source IP and not the proxy’s IP.
destroy users need to log out (but we wont talk about that anymore here)
This works, but you shouldn’t trust me. We need tests around the create action!
Unit Testing the IP Whitelist with RSpec
Our Controller Spec needs to pass all 3 pieces of information (username, password & ip address) to the controller.
Passing the username and password is pretty standard and something I’m sure you’ve done before.
They come from a form so we pass them as a hash in the second argument to post.
spec/controllers/sessions_controller_spec.rb
Unfortunately we can’t pass the IP the same way because the post method in ActionController::TestCase
doesn’t support passing headers in (but it does take the session or flash - that’s interesting to remember for some other time).
If we keep looking around it turns out the ActionDispatch::TestRequest object has a nice convenience method that lets us specify the remote_addr directly.
If we add a line to our spec we can handle the case where the IP comes in the REMOTE_ADDR HTTP header.
spec/controllers/sessions_controller_spec.rb
We still need to deal with the X-Forwarded-For case. While Rails doesn’t give us a convenience method, by looking at the implementation of the remote_addr= method we can see
how to set this header ourselves.
spec/controllers/sessions_controller_spec.rb
Putting it all together we end up with a controller spec that looks like this.
spec/controllers/sessions_controller_spec.rb
To sum up we can
pass parameters as a hash in the post method
post :create, {:username => 'alex', :password => 'secret'}
set the remote_addr on the request with a convenience method
request.remote_addr = '192.168.1.100'
et the X-Forwarded-For directly on the requests’s environment hash
request.env['X-Forwarded-For'] = '192.168.1.100'
Integration Testing the IP Whitelist in a Cucumber Feature
We face a similar issue when writing our cucumber scenarios - its easy to pass the username and password but harder to pass the IP address.
The solution turns out to be similar but not quite exactly the because our Cucumber steps will use Capybara instead of ActionController::TestCase directly.
Before we look into how to implement the steps, let’s write the feature we want which will help us define the steps we need.
features/authentication.feature
We immediately realize we don’t know how to write the first step
features/step_definitions/authentication_steps.rb
To figure this out we need to dig into how capybara works.
We don’t call post in ActionController::TestCase directly instead letting capybara do it for us.
To see what capybara is doing we can skip that step and implement the login step
features/step_definitions/authentication_steps.rb
and edit the SessionsController to show us the stack trace.
app/controllers/sessions_controller.rb
The stack trace is very big but if we look closely, somewhere in the middle of it we see lines below that show how capybara uses
the rack-test gem to submit our form.
Looking at the Rack::Test#post method we see something similar to what we saw before in
ActionController::TestCase
but its not quite identical. It takes the env as a parameter so we need to figure out how to inject our header in there.
Following the stack trace up we see the env passed into Rack::Test::Session.post comes from Capybara::RackTest::Browser and it turns out that env is
computed in the Capybara::RackTest::Browser#env method.
The key is in the line env.merge!(options[:headers]) if options[:headers] and those options are delegated to the driver.
Now we know how to inject our IP address onto the driver’s options.
features/step_definitions/authentication_steps.rb
Putting it all together we can write all our steps
Since we’re testing the IP logic at both the unit level with RSpec and integration level with Cucumber and Capybara we can be pretty sure it’s all going to work correctly.