Rails 测试:确保在所有控制器和操作中强制执行授权 (Pundit)

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

我正在为使用 Pundit 进行授权的 Rails 4.2 应用程序编写 RSpec 测试。

我想测试是否在所有控制器的所有操作中强制执行授权,以避免在开发人员忘记调用 policy_scope(在 #index 动作)和 authorize(在所有其他动作上)。

一种可能的解决方案是在所有控制器单元测试中模拟这些方法。类似于 expect(controller).to receive(:authorize).and_return(true)expect(controller).to receive(:policy_scope).and_call_original。但是,这会导致大量代码重复。此行可以放在 spec/support 中的自定义匹配器或辅助方法中,但在每个控制器的每个规范中调用它似乎也是重复的。关于如何以 DRY 方式实现此目标的任何想法?

如果您想知道,Pundit 的政策 类 是单独测试的,如 this post 所示。

我觉得你可以在 spec_helper 中使用类似的东西。请注意,我假设一个命名约定,其中索引级别答案中包含单词 "index",因此您的规范可能如下所示:

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

这里是整体配置:

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

这是一个使用 shared_examples、before :suite 挂钩和元编程的示例,可能会满足您的需要。

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

及 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 已经提供了一种机制来保证开发人员在执行控制器操作期间不会忘记授权:

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

如果未执行身份验证,这会指示 Pundit raise。只要您的所有控制器都经过测试,这将导致规范失败。

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

我正在发布我最近一次尝试的代码。

请注意:

  • 您可能不应该使用此代码,因为它感觉过于复杂和 hacky。
  • 发生异常后调用authorizepolicy_scope无效。如果测试的操作调用 Active Record 方法,例如 findupdatedestroy,但没有提供有效参数,则会发生异常。以下代码创建具有空值的假参数。空 ID 无效,将导致 ActiveRecord::RecordNotFound 异常。找到解决方案后将更新代码。

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