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

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

Mike Knepper, Software Craftsman

Mike Knepper likes idempotent functions, the whole-tone scale, and extra passes that lead to open layups.

Interested in 8th Light's services? Let's talk.

Contact Us