简体   繁体   中英

Rails testing: ensure authorization (Pundit) is enforced in all controllers and actions

I'm writing RSpec tests for a Rails 4.2 application which uses Pundit for authorization.

I'd like to test whether authorization is enforced in all actions of all controllers, to avoid unintentionally providing public access to sensitive data in case a developer forgets to call policy_scope (on #index actions) and authorize (on all other actions).

One possible solution is to mock these methods in all controller unit tests. Something like expect(controller).to receive(:authorize).and_return(true) and expect(controller).to receive(:policy_scope).and_call_original . However, that would lead to a lot of code repetition. This line could be placed within a custom matcher or a helper method in spec/support but calling it in every spec of every controller also seems repetitive. Any ideas on how to achieve this in a DRY way?

In case you are wondering, Pundit's policy classes are tested separately, as shown in this post .

I feel like you could use something like this up in spec_helper. Note that I'm assuming a naming convention where you have the word "index" in the index level answers, so that your spec might look like this:

describe MyNewFeaturesController, :type => :controller do

  describe "index" do
    # all of the index tests under here have policy_scope applied
  end

  # and these other tests have authorize applied
  describe 'show' do
  end

  describe 'destroy' do
  end
end

and here is the overall configuration:

RSpec.configure do |config|
  config.before(:each, :type => :controller) do |spec|
    # if the spec description has "index" in the name, then use policy-level authorization
    if spec.metadata[:full_description] =~ /\bindex\b/
      expect(controller).to receive(:policy_scope).and_call_original
    else 
      expect(controller).to receive(:authorize).and_call_original
    end
  end
end

Here is an example using shared_examples, the before :suite hook, and metaprogramming that might get at what you need.

RSpec.configure do |config|
  config.before(:suite, :type => :controller) do |spec|
      it_should_behave_like("authorized_controller")
  end
end

and over in spec_helper

shared_examples_for "authorized_controller" do
  # expects controller to define index_params, create_params, etc
  describe "uses pundit" do 
    HTTP_VERB = {
      :create => :post, :update=>:put, :destroy=>:delete 
    }
    %i{ new create show edit index update destroy}.each do |action|
       if controller.responds_to action
        it "for #{action}" do
          expect(controller).to receive(:policy_scope) if :action == :index
          expect(controller).to receive(:authorize) unless :action == :index
          send (HTTP_VERB[action]||:get), action
        end
      end
    end
  end
end

Pundit already provides a mechanism to guarantee a developer can't forget to authorize during the execution of a controller action:

class ApplicationController < ActionController::Base
  include Pundit
  after_action :verify_authorized, except: :index
  after_action :verify_policy_scoped, only: :index
end

This instructs Pundit to raise if the auth wasn't performed. As long as all your controllers are tested, this will cause the spec to fail.

https://github.com/elabs/pundit#ensuring-policies-and-scopes-are-used

I'm posting the code for my latest attempt.

Please note that:

  • You should probably not use this code as it feels overly complex and hacky.
  • It does not work if authorize or policy_scope is called after an exception happens. Exceptions will occur if a tested action calls Active Record methods such as find , update and destroy without providing them valid parameters. The following code creates fake parameters with empty values. An empty ID is invalid and will result in a ActiveRecord::RecordNotFound exception. Will update the code once I find a solution for this.

spec/controllers/all_controllers_spec.rb

# Test all descendants of this base controller controller
BASE_CONTROLLER = ApplicationController

# To exclude specific actions:
# "TasksController" => [:create, :new, :index]
# "API::V1::PostsController" => [:index]
#
# To exclude entire controllers:
# "TasksController" => nil
# "API::V1::PostsController" => nil
EXCLUDED = {
  'TasksController' => nil
}

def expected_auth_method(action)
  action == 'index' ? :policy_scope : :authorize
end

def create_fake_params(route)
  # Params with non-nil values are required to "No route matches..." error
  route.parts.map { |param| [param, ''] }.to_h
end

def extract_action(route)
  route.defaults[:action]
end

def extract_http_method(route)
  route.constraints[:request_method].to_s.delete("^A-Z")
end

def skip_controller?(controller)
  EXCLUDED.key?(controller.name) && EXCLUDED[controller.name].nil?
end

def skip_action?(controller, action)
  EXCLUDED.key?(controller.name) &&
    EXCLUDED[controller.name].include?(action.to_sym)
end

def testable_controllers
  Rails.application.eager_load!
  BASE_CONTROLLER.descendants.reject {|controller| skip_controller?(controller)}
end

def testable_routes(controller)
  Rails.application.routes.set.select do |route|
    route.defaults[:controller] == controller.controller_path &&
      !skip_action?(controller, extract_action(route))
  end
end

# Do NOT name the loop variable "controller" or it will override the
# "controller" object available within RSpec controller specs.
testable_controllers.each do |tested_controller|
  RSpec.describe tested_controller, :focus, type: :controller do
    # login_user is implemented in spec/support/controller_macros.rb
    login_user
    testable_routes(tested_controller).each do |route|
      action = extract_action(route)
      http_method = extract_http_method(route)

      describe "#{http_method} ##{action}" do
        it 'enforces authorization' do
          expect(controller).to receive(expected_auth_method(action)).and_return(true)
          begin
            process(action, http_method, create_fake_params(route))
          rescue ActiveRecord::RecordNotFound
          end
        end
      end
    end
  end
end

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM