Imagine you have a concern, whose class method declares authorization rules. It installs a before_action that routes each request one of three ways:
- allowed (renders)
- denied (redirect to an access-denied page)
- login (redirect to sign-in)
module AccessGate
extend ActiveSupport::Concern
class_methods do
def permitted_roles(*roles)
before_action { enforce(roles) }
end
end
private
def enforce(roles)
# Extremely simplified
return if roles.include?(current_role) # allowed
current_role == :guest ? redirect_to(SSO.login_url(self)) : redirect_to("/denied")
end
end
1. Testing strategy
Every controller that uses your concern should only test the things that are important to itself. You want the test coverage for the concern itself to be more exhaustive and to sit in a separate location.
The most common strategy is to have a test/ route with dummy controllers for testing such cases. However, to exercise all behaviors of your concern you might need multiple different controller setups. This can become pretty messy.
The trick: spin up throwaway controllers with rspec-rails' anonymous controller helper, drive real requests, and classify the response.
2. Simplest possible case
Describe the concern as a controller spec. controller(Base) { … } makes a throwaway controller inheriting from Base:
RSpec.describe AccessGate, type: :controller do
# Throwaway controller
controller(ApplicationController) do
# We assume that ApplicationController includes AccessGate. Otherwise, simply include it here.
permitted_roles :admin
def index = head(:ok)
end
# Temporary routes
before { routes.draw { get "index" => "anonymous#index" } }
# Your specs
it { sign_in_as :admin; get :index; expect(response).to have_http_status(:ok) }
it { sign_in_as :editor; get :index; expect(response).to redirect_to("/denied") }
end
3. Roadblocks
The anonymous controller(Base) { … } helper handles three things for you:
- It sets the controller under test for the controller spec. The alternative
tests SomeControllerisn't sufficient here. - It isolates routes. A bare
routes.drawmutates the globalRails.application.routes. The helper swaps in a freshRouteSetand restores it afterwards. - It names the subclass
AnonymousController, so routes readanonymous#index.
Still your job
- Put actions into the anonymous controller.
- Draw the routes for them.
- Stub URLs that depend on the current request. Did you notice the
SSO.login_url(self)in our initial example? That would fail. Inbound requests use the temporary route set, buturl_forinside the anonymous controller (e.g. a login redirect building a return URL) resolves against the app routes. Your anonymous controller has no routes there, so you will run into anUrlGenerationError. A redirect to a route that exists app-wide (e.g./denied) is fine; only current-request URLs break.
General advice
Unless your controller is fully app-agnostic, use your real ApplicationController as a base class, not ActionController::Base. Your concern most likely relies on before_actions (and their ordering), helpers, other private methods and rescue_from. Inheriting from ActionController::Base means rebuilding that stack by hand and testing against a reconstruction that can drift.
4. Complete example
require "rails_helper"
RSpec.describe AccessGate, "#permitted_roles", type: :controller do
shared_context "gated controller" do
before do
# Stub URLs that depend on the current request
allow(SSO).to receive(:login_url).and_return("https://sso.example/login")
# You can probably share your routes across all specs in this file
routes.draw { get "index" => "anonymous#index" }
end
def sign_in_as(role) = session[:role] = role
# Define a nice mapping for the results you want to observe
def outcome
return :allowed if response.successful?
case response.location
when %r{/denied} then :denied
when %r{/login} then :login
else "unexpected: #{response.status} #{response.location}"
end
end
end
# Assert the outcome for each of your roles
shared_examples "a gate" do |expectations|
expectations.each do |role, expected|
it "#{role} -> #{expected}" do
sign_in_as(role) unless role == :anonymous
get :index
expect(outcome).to eq(expected)
end
end
end
# Specs for use case 1
describe "permitted_roles :admin" do
controller(ApplicationController) do
permitted_roles :admin
def index = head(:ok)
end
include_context "gated controller"
it_behaves_like "a gate", {
admin: :allowed,
editor: :denied,
anonymous: :login
}
end
# Specs for use case 2
describe "permitted_roles :admin, :editor" do
controller(ApplicationController) do
permitted_roles :admin, :editor
def index = head(:ok)
end
include_context "gated controller"
it_behaves_like "a gate", {
admin: :allowed,
editor: :allowed,
viewer: :denied,
anonymous: :login
}
end
end
You get:
- One anonymous controller per "concern use case"
- A shared context for the harness (stubs, routes, the outcome classifier, utilities like the
sign_in_asmethod) - A declarative
role × expected outcomematrix