Apprentice Blog of the Week: Stubbing Authentication and Authorization in Controller Specs

Apprentice Blog of the Week: Stubbing Authentication and Authorization in Controller Specs

Mike Knepper
Mike Knepper

July 01, 2014

Controllers are powerful and complicated objects that easily accumulate responsibilities. Even when following the classic Rails convention of "Skinny Controllers, Fat Models," controllers still have a lot to handle. One extremely important responsibility is checking user authentication and authorization. The typical way to handle these two critical aspects of a web application in Rails is through the use of before_action or before_filter statements. These lines are called before specified actions in the controller and are capable of redirecting users before reaching those actions. For example:

 1 class NuclearLaunchCodesController < ApplicationController
 2 before_action :authenticate_user
 3 before_action :require_admin, only: [:new, :create]
 4
 5 def index
 6 @codes = NuclearLaunchCode.all
 7 end
 8
 9 def show
10 @code = NuclearLaunchCode.find(params[:id])
11 @presenter = CodePresenter.new(@code)
12 end
13
14 def new
15 @code = NuclearLaunchCode.new
16 end
17
18 def create
19 @code = NuclearLaunchCode.new(params[:code])
20 @code.save!
21 redirect_to(code_path(@code)), notice: "Successfully created new Nuclear Launch Code!"
22 rescue StandardError => e
23 flash[:error] = [e.message]
24 redirect_to new_code_path(@code)
25 end
26
27 private
28
29 def current_user
30 @current_user = User.find_by_id(session[:user_id]) if session[:user_id]
31 end
32
33 def authenticate_user
34 redirect_to(login_url, notice: "You must be signed in to view that page") unless current_user.present?
35 end
36
37 def require_admin
38 redirect_to(root_url, notice: "You are not authorized to perform that action") unless current_user.admin?
39 end
40 end

We don't want any random person browsing the web to access the index or show pages of our top secret nuclear launch codes, so we define a before_action filter to make sure a user is signed in. This method redirects every request to the login page unless a user is present. An unauthenticated browser cannot ever access those actions. In addition to that rule, our new and create actions have even tighter security: they can only be performed by admin users. Like the authenticate_user filter, require_admin will redirect any user to the home page unless they are an admin user.

This relatively simple technique actually works great for production environments. However, it definitely complicates the spec: if we try to run these actions in a test, the filters will boot us away from any action before we reach it, and thus all tests will fail. We could create a user in the spec file and log in before running the specs, but that can get extremely complicated very quickly. The users table may have validations that we'd need to know about and keep track of to successfully create a test user, and we'd need to manipulate the session hash to simulate being logged in, etc. But do not fear—there is a better way.

Authentication

First let's address authentication. Hopefully, logging in and out is tested somewhere else in the application (it had better be!), so we know that if those tests pass, the filter will work here, too. If it's safe to assume in the context of this test that the authentication method works, we just want to bypass it. It turns out we can do this fairly easily by stubbing the before_action. Stubbing is a concept that often seems intimidating, but is actually quite simple: stubbing a method is like saying, "Any time this method is called, don't actually call it, just return X." The syntax looks like this: object.stub(:method) { return_value }. In this case, our object is the controller being tested, the method is the authenticate_user method, and it turns out our return value is nothing at all. Controller filters don't have a return value when they pass, but instead simply allow the controller to continue on to the requested action. So in our spec, we can simply write this to pass our index and show specs:

 1 describe NuclearLaunchCodesController do
 2 let(:test_code_1) { NuclearLaunchCodeFactory.create }
 3 let(:test_code_2) { NuclearLaunchCodeFactory.create }
 4
 5 it "assigns all codes to @codes" do
 6 controller.stub(:authenticate_user)
 7 get :index
 8 expect(assigns(:codes)).to eq([test_code_1, test_code_2])
 9 end
10
11 it "assigns the correct code to @code" do
12 controller.stub(:authenticate_user)
13 get :show, :id => test_code_2.id
14 expect(assigns(:code)).to eq(test_code_2)
15 end
16 end

(I'm hiding the details of a spec_helper factory, which generates test launch codes.)

When get :index and get :show are called, the controller intends to make the before_action :authenticate_user call. But the controller recognizes that the method has been stubbed out, so it ignores the production code and continues right along. Perfect!

Authorization

What about the new and create actions? We could stub out the call to the require_admin filter like we do for authentication, but let's assume that this is the only place in our app that requires admin permissions. We want to be 100 percent sure that regular users can't create new launch codes, and this is the only place that will really be tested.

The trick here is to allow the before_action :require_admin call to be made, but simulate being logged in as an admin user in one test and as a regular user in another. But remember, we want to do this without making user objects. Fortunately, current_user is a method on the controller that returns and/or fetches the current user from the session hash (technically this would be in the ApplicationController, but for readability I've put it in the controller under test). Because current_user is a method call, we can stub it and tell it to return some dummy admin user. However, that returned dummy can't just be a primitive Ruby object like a String, because it needs to have the method admin? defined (otherwise we'd get an "undefined method" error). What we need is a "double":

1 admin_user = double('a user', :admin? => true)
2 controller.stub(:current_user) { admin_user }

The first line creates the double. The first argument, 'a user', is little more than an identifier, but the second argument is critical: it allows the object to have the method admin? called on it and return true. Essentially it is defining the method and stubbing it, all in one.

Once the double is set up, we stub the controller's current_user method to return that double. The line can also be written like this: allow(controller).to receive(:current_user).and_return(admin_user).

We can now write the test for the new action:

1 it "assigns a new instance of a NuclearLaunchCode to @code" do
2 admin_user = double('a user', :admin? => true)
3 controller.stub(:current_user) { admin_user }
4 get :new
5 expect(assigns(:code)).to be_a_new(NuclearLaunchCode)
6 end

Here, get :new is called. The controller recognizes new as an action that necessitates first calling the require_admin filter. The filter will redirect to the root page, unless current_user.admin?. What is current_user, a variable? A method? It's a method that has been stubbed on the controller to return a double object. Okay, next call admin? on that object. Is it defined? Yes, in the definition/instantiation of the double, admin? is set to return true. The filter therefore does not redirect to root, but instead allows the request get :new to continue on.

Final Refactor

After some refactoring, our controller spec becomes quite readable—the requisite high-level behavior is provided without cluttering up the specs, keeping the focus on the object under test: the controller.

 1 describe NuclearLaunchCodesController do
 2 let(:code_1) { NuclearLaunchCodeFactory.create }
 3 let(:code_2) { NuclearLaunchCodeFactory.create }
 4
 5 before :each do
 6 controller.stub(:authenticate_user)
 7 end
 8
 9 def log_in_as_admin_user
10 admin_user = double('an admin user', admin: true)
11 allow(controller).to receive(:current_user).and_return(admin_user)
12 end
13
14 def log_in_as_regular_user
15 regular_user = double('a regular user', admin: false)
16 allow(controller).to receive(:current_user).and_return(regular_user)
17 end
18
19 context "#index" do
20 it "assigns all launch codes to @codes" do
21 get :index
22 expect(assigns(:codes)).to eq([code_1, code_2])
23 end
24 end
25
26 context "#show" do
27 it "assigns the correct launch code to @code" do
28 get :show, :id => code_2.id
29 expect(assigns(:code)).to eq(code_2)
30 end
31 end
32
33 context "#new" do
34 it "assigns a new NuclearLaunchCode to @code" do
35 log_in_as_admin_user
36 get :new
37 expect(assigns(:code)).to be_a_new(NuclearLaunchCode)
38 end
39
40 it "does not allow non-admin users to see the new code page" do
41 log_in_as_regular_user
42 get :new
43 expect(response).to redirect_to(root_url)
44 end
45 end
46
47 context "#create" do
48 it "allows admins to create a new NuclearLaunchCode" do
49 log_in_as_admin_user
50 params = {:launch_sequence => 12345, :target => "Moscow"}
51 expect{
52 post :create, params
53 }.to change(NuclearLaunchCode, :count).by(1)
54 new_code = NuclearLaunchCode.find_by_launch_sequence(12345)
55 expect(new_code.target).to eq("Moscow")
56 end
57
58 it "doesn't allow non-admins to create a new code" do
59 log_in_as_regular_user
60 params = {:launch_sequence => 12345, :target => "Moscow"}
61 expect{
62 post :create, params
63 }.to_not change(NuclearLaunchCode, :count)
64 end
65 end
66 end