How to test Pundit Scopes in Rspec?
Asked Answered
B

2

8

I've got a pretty simple Pundit policy with a scope for different user roles. I can't figure out how to test it in Rspec. Specifically, I don't know how to tell the scope what user is logged in before accessing the scope.

Here is what I've tried:

let(:records) { policy_scope(Report) } 

context 'admin user' do
  before(:each) { sign_in(admin_user) }
  it { expect(reports.to_a).to match_array([account1_report, account2_report]) }
end

context 'client user' do
  before(:each) { sign_in(account2_user) }
  it { expect(reports.to_a).to match_array([account2_report]) }
end

When I run Rspec, I get:

NoMethodError: undefined method `sign_in' for #<RSpec::ExampleGroups::ReportPolicy::Scope:0x00007f93241c67b8>

I use sign_in extensively in controller tests, but I guess that doesn't apply in a Policy test.

The Pundit docs says only:

Pundit does not provide a DSL for testing scopes. Just test it like a regular Ruby class!

So...does anyone have an example of testing a Pundit scope for a specific user? How do I tell the scope what current_user is?


FWIW, here's the essence of my policy:

class ReportPolicy < ApplicationPolicy
  def index?
    true
  end

  class Scope < Scope
    def resolve
      if user.role == 'admin'
        scope.all
      else
        scope.where(account_id: user.account_id)
      end
    end
  end
end

In my controller, I call it as follows. I've confirmed that this works correctly in the real world, with admins seeing all reports, and other users only seeing reports for their account:

reports = policy_scope(Report)
Bicker answered 23/1, 2019 at 15:27 Comment(0)
R
19

You can instantiate a policy scope with:

Pundit.policy_scope!(user, Report)

Which is short for:

ReportPolicy::Scope.new(user, Report).resolve

Note that you don't need to do any actual steps of signing the user in. user is just an object that your policy scope takes as an initializer argument. Pundit is after all just plain old OOP.

class ApplicationPolicy
  # ...
  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      scope.all
    end
  end
end

As to the actual spec I would write it as:

require 'rails_helper'
require 'pundit/rspec' # optional - but includes some nice matchers for policies

RSpec.describe ReportPolicy, type: :policy do
  let(:user) { User.new }
  let(:scope) { Pundit.policy_scope!(user, Report) } 
  # ... setup account1_report etc
    
  describe "Scope" do
    context 'client user' do
      it 'allows a limited subset' do
        expect(scope.to_a).to match_array([account2_report])
      end 
    end
    context 'admin user' do
      let(:user) { User.new(role: 'admin') }
      it 'allows access to all the reports' do
        expect(scope.to_a).to match_array([account1_report, account2_report])
      end
    end
  end
end

Avoid constructs such as it { expect ... } and use it blocks that describe the actual behaviour that you are testing or you will end up with really cryptic failure messages and tests that are hard to understand. The one-liner syntax it { is_expected.to ... } should only be used to help avoid duplication in situations where the doc string and the matcher used in the example mirror each other exactly.

Robson answered 24/1, 2019 at 0:14 Comment(1)
You can use a shared context to dry policy specs up.Robson
B
2

Replacing

let(:records) { policy_scope(Report) } 

...with this:

let(:records) { ReportPolicy::Scope.new(user, Report).resolve }

...allows specifying the user to the policy. No call to sign_in is required.

Here is the complete solution:

let(:records) { ReportPolicy::Scope.new(user, Report).resolve }

context 'admin user' do
  let(:user) { admin_user }
  it { expect(reports.to_a).to match_array([account1_report, account2_report]) }
end

context 'client user' do
  let(:user) { account2_user }
  it { expect(reports.to_a).to match_array([account2_report]) }
end
Bicker answered 23/1, 2019 at 16:12 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.