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:


class NuclearLaunchCodesController < ApplicationController
		before_action :authenticate_user
		before_action :require_admin, only: [:new, :create]

		def index
				@codes = NuclearLaunchCode.all
		end

		def show
				@code = NuclearLaunchCode.find(params[:id])
				@presenter = CodePresenter.new(@code)
		end

		def new
				@code = NuclearLaunchCode.new
		end

		def create
				@code = NuclearLaunchCode.new(params[:code])
				@code.save!
				redirect_to(code_path(@code)), notice: "Successfully created new Nuclear Launch Code!"
		rescue StandardError => e
				flash[:error] = [e.message]
				redirect_to new_code_path(@code)
		end

		private

		def current_user
				@current_user = User.find_by_id(session[:user_id]) if session[:user_id]
		end

		def authenticate_user
				redirect_to(login_url, notice: "You must be signed in to view that page") unless current_user.present?
		end

		def require_admin
				redirect_to(root_url, notice: "You are not authorized to perform that action") unless current_user.admin?
		end
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:


describe NuclearLaunchCodesController do
		let(:test_code_1) { NuclearLaunchCodeFactory.create }
		let(:test_code_2) { NuclearLaunchCodeFactory.create }

		it "assigns all codes to @codes" do
				controller.stub(:authenticate_user)
				get :index
				expect(assigns(:codes)).to eq([test_code_1, test_code_2])
		end

		it "assigns the correct code to @code" do
				controller.stub(:authenticate_user)
				get :show, :id => test_code_2.id
				expect(assigns(:code)).to eq(test_code_2)
		end
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":

admin_user = double('a user', :admin? => true)
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:

it "assigns a new instance of a NuclearLaunchCode to @code" do
		admin_user = double('a user', :admin? => true)
		controller.stub(:current_user) { admin_user }
		get :new
		expect(assigns(:code)).to be_a_new(NuclearLaunchCode)
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.

describe NuclearLaunchCodesController do
		let(:code_1) { NuclearLaunchCodeFactory.create }
		let(:code_2) { NuclearLaunchCodeFactory.create }

		before :each do
				controller.stub(:authenticate_user)
		end

		def log_in_as_admin_user
				admin_user = double('an admin user', admin: true)
				allow(controller).to receive(:current_user).and_return(admin_user)
		end

		def log_in_as_regular_user
				regular_user = double('a regular user', admin: false)
				allow(controller).to receive(:current_user).and_return(regular_user)
		end

		context "#index" do
				it "assigns all launch codes to @codes" do
						get :index
						expect(assigns(:codes)).to eq([code_1, code_2])
				end
		end

		context "#show" do
				it "assigns the correct launch code to @code" do
						get :show, :id => code_2.id
						expect(assigns(:code)).to eq(code_2)
				end
		end

		context "#new" do
				it "assigns a new NuclearLaunchCode to @code" do
						log_in_as_admin_user
						get :new
						expect(assigns(:code)).to be_a_new(NuclearLaunchCode)
				end

				it "does not allow non-admin users to see the new code page" do
						log_in_as_regular_user
						get :new
						expect(response).to redirect_to(root_url)
				end
		end

		context "#create" do
				it "allows admins to create a new NuclearLaunchCode" do
						log_in_as_admin_user
						params = {:launch_sequence => 12345, :target => "Moscow"}
						expect{
								post :create, params
						}.to change(NuclearLaunchCode, :count).by(1)
						new_code = NuclearLaunchCode.find_by_launch_sequence(12345)
						expect(new_code.target).to eq("Moscow")
				end

				it "doesn't allow non-admins to create a new code" do
						log_in_as_regular_user
						params = {:launch_sequence => 12345, :target => "Moscow"}
						expect{
								post :create, params
						}.to_not change(NuclearLaunchCode, :count)
				end
		end
end