Comparing Pundit RSpec test Approaches

Two approaches, subtle differences. Which one is better?

Posted by Tobias L. Maier on June 19, 2023

I am an old-time CanCanCan user and recently started to consider switching to Pundit.

I am also a big fan of RSpec. Given how important Authorization (AuthZ) is, I want to test it properly.

I found two approaches to test Pundit policies with RSpec. Both approaches are described in the Pundit README and I want to compare them in this post.

The approaches

pundit/rspec approach is the native approach for Pundit. It introduces a simple DSL consisting of a permit matcher permissions to group them.

pundit-matchers approach is a third-party approach. It has a more extensive DSL with a large list of matchers.

You can see the key differences when looking at the examples given in their respective READMEs. (see below)

You will see that the pundit/rspec approach structures the tests around the actions, while pundit-matchers structures the tests around the user segments or other contexts.

pundit/rspec approach

Here, everything is centered around the permissions matcher. One tests the actions of the policy one-by-one.

describe PostPolicy do
  subject { described_class }

  permissions :update?, :edit? do
    it "denies access if post is published" do
      expect(subject).not_to permit(User.new(admin: false), Post.new(published: true))
    end

    it "grants access if post is published and user is an admin" do
      expect(subject).to permit(User.new(admin: true), Post.new(published: true))
    end

    it "grants access if post is unpublished" do
      expect(subject).to permit(User.new(admin: false), Post.new(published: false))
    end
  end
end

pundit-matchers approach

Here, the permit_* and forbid_* matchers are used to test all actions at once. The code is structured by the user roles. One defines a context for each user or what else is driving the context of permissions and then all relevant actions are tested at once.

require 'rails_helper'

RSpec.describe ArticlePolicy do
  subject { described_class.new(user, article) }

  let(:article) { Article.new }

  context 'with visitors' do
    let(:user) { nil }

    it { is_expected.to permit_only_actions(%i[index show]) }
  end

  context 'with administrators' do
    let(:user) { User.new(administrator: true) }

    it { is_expected.to permit_all_actions }
  end
end

This approach reminds me very well to how I tested my CanCanCan abilities. I had a spec file for each class for which I defined abilities and within that file, I had a context for each user role and other context drivers.

Comparing them in a more real-life example

For me, the examples of the READMEs are too small to understand the implications of these differences. So I migrated my CanCanCan tests to Pundit and tried both approaches.

Context of the real-life example

My application Librario is a library management system primarily targeting small and medium-sized companies.

It has the models Accounts, Users, and Roles. An Account has many User and each User may have roles like admin or librarian - or no special role at all.

The policy used for this comparison was AccountPolicy, which relates to the Account model.

The pundit/rspec approach in the real-life example

# frozen_string_literal: true

require 'rails_helper'

# rubocop:disable RSpec/MultipleMemoizedHelpers
RSpec.describe AccountPolicy, type: :policy do
  subject(:policy) { described_class }

  let(:account) { create(:account, :with_legacy_subscription) }
  let(:guest_user) { User.new(account:) }
  let(:user) { create(:user, account:) }
  let(:admin) { create(:user, :admin, account:) }
  let(:librarian) { create(:user, :librarian, account:) }

  describe AccountPolicy::Scope do
    subject(:resolved_scope) { described_class.new(user, Account.all).resolve }

    it "restricts the scope to the user's account" do
      expect(resolved_scope.to_sql).to eq Account.where(id: user.account_id).to_sql
    end
  end

  permissions :login_with_password?, :login_with_sso? do
    it { is_expected.not_to permit(user, account) }
    it { is_expected.not_to permit(admin, account) }
    it { is_expected.not_to permit(librarian, account) }
  end

  permissions :login_with_password? do
    context 'when user is a guest' do
      it 'grants access if SSO feature is disabled' do
        stub_plan(id: :ingenieurbuero_klein_2014, limits: { user: { sso: false } }, active: true)
        expect(policy).to permit(guest_user, account)
      end

      context 'when SSO feature is enabled' do
        before { stub_plan(id: :ingenieurbuero_klein_2014, limits: { user: { sso: true } }, active: true) }

        context 'when sso_disable_username_password preference is true' do
          before { account.preferences.sso_disable_username_password = true }

          it 'denies access if subdomain present' do
            account.subdomain = 'test'
            expect(policy).not_to permit(guest_user, account)
          end

          it 'grants access if subdomain blank' do
            account.subdomain = nil
            expect(policy).to permit(guest_user, account)
          end
        end

        it 'grants access if sso_disable_username_password preference is false' do
          account.preferences.sso_disable_username_password = false
          expect(policy).to permit(guest_user, account)
        end
      end
    end
  end

  permissions :login_with_sso? do
    context 'when user is a guest' do
      it 'denies access if SSO feature is disabled' do
        stub_plan(id: :ingenieurbuero_klein_2014, limits: { user: { sso: false } }, active: true)
        expect(policy).not_to permit(guest_user, account)
      end

      context 'when SSO feature is enabled' do
        before { stub_plan(id: :ingenieurbuero_klein_2014, limits: { user: { sso: true } }, active: true) }

        it 'denies access if subdomain is blank' do
          account.subdomain = nil
          expect(policy).not_to permit(guest_user, account)
        end

        it 'grants access if subdomain is present' do
          account.subdomain = 'test'
          expect(policy).to permit(guest_user, account)
        end
      end
    end
  end

  permissions :show? do
    it { is_expected.not_to permit(guest_user, account) }
    it { is_expected.to permit(user, account) }
    it { is_expected.to permit(admin, account) }
    it { is_expected.to permit(librarian, account) }
  end

  permissions :show?, :update? do
    context 'when users are not member of the account' do
      let(:other_account) { create(:account) }

      it { is_expected.not_to permit(guest_user, other_account) }
      it { is_expected.not_to permit(user, other_account) }
      it { is_expected.not_to permit(admin, other_account) }
      it { is_expected.not_to permit(librarian, other_account) }
    end
  end

  permissions :create? do
    it { is_expected.not_to permit(user, Account) }
    it { is_expected.not_to permit(admin, Account) }
    it { is_expected.not_to permit(librarian, Account) }

    context 'when Config.bc_disable_account_new is true' do
      before { allow(Config).to receive(:bc_disable_account_new).and_return(true) }

      it { is_expected.not_to permit(guest_user, Account) }
    end

    context 'when Config.bc_disable_account_new is false' do
      before { allow(Config).to receive(:bc_disable_account_new).and_return(false) }

      it { is_expected.to permit(guest_user, Account) }
    end
  end

  permissions :update? do
    it { is_expected.not_to permit(guest_user, account) }
    it { is_expected.not_to permit(user, account) }
    it { is_expected.to permit(admin, account) }
    it { is_expected.not_to permit(librarian, account) }
  end

  permissions :destroy? do
    it { is_expected.not_to permit(guest_user, account) }
    it { is_expected.not_to permit(user, account) }
    it { is_expected.not_to permit(admin, account) }
    it { is_expected.not_to permit(librarian, account) }
  end

  describe '#permitted_attributes_for_create' do
    subject(:permitted_attributes) { described_class.new(user, account).permitted_attributes_for_create }

    context 'with guests' do
      let(:user) { User.new(account:) }

      context 'with custom subdomain feature enabled' do
        before { stub_plan id: :ingenieurbuero_klein_2014, limits: { account: { custom_subdomain: true } }, active: true }

        it { is_expected.to include(:subdomain) }
        it { is_expected.to contain_exactly(:name, :subdomain, users_attributes: [], subscription_attributes: []) }
      end

      context 'with custom subdomain feature disabled' do
        before { stub_plan id: :ingenieurbuero_klein_2014, limits: { account: { custom_subdomain: false } }, active: true }

        it { is_expected.not_to include(:subdomain) }
        it { is_expected.to contain_exactly(:name, users_attributes: [], subscription_attributes: []) }
      end
    end

    context 'with users' do
      let(:user) { create(:user, account:) }

      it { is_expected.to be_empty }
    end

    context 'with librarians' do
      let(:user) { create(:user, :librarian, account:) }

      it { is_expected.to be_empty }
    end

    context 'with admins' do
      let(:user) { create(:user, :admin, account:) }

      it { is_expected.to be_empty }
    end
  end

  describe '#permitted_attributes_for_update' do
    subject(:permitted_attributes) { described_class.new(user, account).permitted_attributes_for_update }

    context 'with guests' do
      let(:user) { User.new(account:) }

      it { is_expected.to be_empty }
    end

    context 'with users' do
      let(:user) { create(:user, account:) }

      it { is_expected.to be_empty }
    end

    context 'with librarians' do
      let(:user) { create(:user, :librarian, account:) }

      it { is_expected.to be_empty }
    end

    context 'with admins' do
      let(:user) { create(:user, :admin, account:) }

      before { allow(Account::PreferencesPolicy).to receive(:new).and_return(instance_double(Account::PreferencesPolicy, permitted_attributes_for_update: [:my_doubled_attributes])) }

      it { is_expected.to contain_exactly(:name, :brand, :remove_brand, preferences: [:my_doubled_attributes]) }
    end
  end
end
# rubocop:enable RSpec/MultipleMemoizedHelpers

The pundit-matchers approach in the real-life example

# frozen_string_literal: true

require 'rails_helper'

RSpec.describe AccountPolicy, type: :policy do
  subject(:policy) { described_class.new(user, account) }

  let(:account) { create(:account, :with_legacy_subscription) }
  let(:resolved_scope) do
    described_class::Scope.new(user, Account.all).resolve
  end

  shared_examples_for 'scope limited to current account' do
    it 'includes only current account in resolved scope' do
      create_list(:account, 3)
      expect(resolved_scope).to contain_exactly(account)
    end
  end

  context 'with guests' do
    let(:user) { User.new(account:) }

    it { is_expected.to permit_new_and_create_actions }
    it { is_expected.to forbid_edit_and_update_actions }
    it { is_expected.to forbid_action(:show) }
    it { is_expected.to forbid_action(:destroy) }
    it { is_expected.to permit_action(:login_with_password) }
    it { is_expected.to forbid_action(:login_with_sso) }

    context 'when Config.bc_disable_account_new is true' do
      before { allow(Config).to receive(:bc_disable_account_new).and_return(true) }

      it { is_expected.to forbid_new_and_create_actions }
    end

    context 'with SSO feature disabled' do
      before { stub_plan(id: :ingenieurbuero_klein_2014, limits: { user: { sso: false } }, active: true) }

      it { is_expected.to permit_action(:login_with_password) }
      it { is_expected.to forbid_action(:login_with_sso) }
    end

    context 'with SSO feature enabled' do
      before { stub_plan(id: :ingenieurbuero_klein_2014, limits: { user: { sso: true } }, active: true) }

      context 'when sso_disable_username_password preference is true' do
        before { account.preferences.sso_disable_username_password = true }

        context 'with subdomain present' do
          before { account.subdomain = 'test' }

          it { is_expected.to forbid_action(:login_with_password) }
          it { is_expected.to permit_action(:login_with_sso) }
        end

        context 'with subdomain blank' do
          before { account.subdomain = nil }

          it { is_expected.to permit_action(:login_with_password) }
          it { is_expected.to forbid_action(:login_with_sso) }
        end
      end

      context 'when sso_disable_username_password preference is false' do
        before { account.preferences.sso_disable_username_password = false }

        it { is_expected.to permit_action(:login_with_password) }
        it { is_expected.to permit_action(:login_with_sso) }
      end
    end

    it_behaves_like 'scope limited to current account'
  end

  shared_examples_for 'a logged in user' do
    it { is_expected.to forbid_new_and_create_actions }
    it { is_expected.to forbid_edit_and_update_actions }
    it { is_expected.to permit_action(:show) }
    it { is_expected.to forbid_action(:destroy) }
    it { is_expected.to forbid_action(:login_with_password) }
    it { is_expected.to forbid_action(:login_with_sso) }

    context 'when accessing a different account' do
      let(:user) { create(:user, account: create(:account)) }

      it { is_expected.to forbid_all_actions }
    end

    it_behaves_like 'scope limited to current account'
  end

  context 'with users' do
    let(:user) { create(:user, account:) }

    it_behaves_like 'a logged in user'
  end

  context 'with librarians' do
    let(:user) { create(:user, :librarian, account:) }

    it_behaves_like 'a logged in user'
  end

  context 'with admins' do
    let(:user) { create(:user, :admin, account:) }

    it { is_expected.to forbid_new_and_create_actions }
    it { is_expected.to permit_edit_and_update_actions }
    it { is_expected.to permit_action(:show) }
    it { is_expected.to forbid_action(:destroy) }
    it { is_expected.to forbid_action(:login_with_password) }
    it { is_expected.to forbid_action(:login_with_sso) }

    context 'when accessing a different account' do
      let(:user) { create(:user, account: create(:account)) }

      it { is_expected.to forbid_all_actions }
    end

    it_behaves_like 'scope limited to current account'
  end

  describe 'permitted attributes for create' do
    subject(:permitted_attributes) { policy.permitted_attributes_for_create }

    context 'with guests' do
      let(:user) { User.new(account:) }

      context 'with custom subdomain feature enabled' do
        before { stub_plan id: :ingenieurbuero_klein_2014, limits: { account: { custom_subdomain: true } }, active: true }

        it { is_expected.to include(:subdomain) }
        it { is_expected.to contain_exactly(:name, :subdomain, users_attributes: [], subscription_attributes: []) }
      end

      context 'with custom subdomain feature disabled' do
        before { stub_plan id: :ingenieurbuero_klein_2014, limits: { account: { custom_subdomain: false } }, active: true }

        it { is_expected.not_to include(:subdomain) }
        it { is_expected.to contain_exactly(:name, users_attributes: [], subscription_attributes: []) }
      end
    end

    context 'with users' do
      let(:user) { create(:user, account:) }

      it { is_expected.to be_empty }
    end

    context 'with librarians' do
      let(:user) { create(:user, :librarian, account:) }

      it { is_expected.to be_empty }
    end

    context 'with admins' do
      let(:user) { create(:user, :admin, account:) }

      it { is_expected.to be_empty }
    end
  end

  describe 'permitted attributes for update' do
    subject(:permitted_attributes) { policy.permitted_attributes_for_update }

    context 'with guests' do
      let(:user) { User.new(account:) }

      it { is_expected.to be_empty }
    end

    context 'with users' do
      let(:user) { create(:user, account:) }

      it { is_expected.to be_empty }
    end

    context 'with librarians' do
      let(:user) { create(:user, :librarian, account:) }

      it { is_expected.to be_empty }
    end

    context 'with admins' do
      let(:user) { create(:user, :admin, account:) }

      before { allow(Account::PreferencesPolicy).to receive(:new).and_return(instance_double(Account::PreferencesPolicy, permitted_attributes_for_update: [:my_doubled_attributes])) }

      it { is_expected.to contain_exactly(:name, :brand, :remove_brand, preferences: [:my_doubled_attributes]) }
    end
  end
end

Conclusion

I like that one can write tests in a very concise way with the pundit-matchers approach. All tests can be quickly written and understood using the rspec one-liner syntax. For example it { is_expected.to permit_action(:login_with_password) } or it { is_expected.to permit_new_and_create_actions } are quite clear in what they intend to test.

Theoretically, the one-liner syntax can be also used with the pundit/rspec approach. However, the output of the tests using rspec --format documentation is not as nice as with the pundit-matchers approach. This is due to the fact that the permit matcher of pundit/rspec expects a object or record as an argument. This looks quite verbose and the important part cannot be seen at a glance. For example, what user (with a certain role) was permitted to act on a certain Account cannot be distinguished from that output.

Example output of a one-liner with the pundit/rspec approach:

  destroy?
    is expected not to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 33, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 33, name: "Strieder, Birkemeyer und Strausa", subdomain: "customer-33", created_at: "20...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>

Example output of a one-liner with the pundit-matchers approach:

  with admins
    is expected to forbid [:create, :new]

On the other side, with the pundit/rspec approach, one can see at a glance what aspects of an action have been tested and what not, as all test cases are grouped under a permissions block.

For pundit-matchers, this is significantly harder. One looses quickly the overview of what is tested and what not (yet). I think this is a problem of the DSL and the way how the tests are structured. One cannot see at a glance what aspects of an action have been covered and which ones not, as all tests covering a certain action are spread over the whole file. This is actually contrary to the overall idea of Pundit, where one has a simple class for a Policy and one simple method for each action. Why should one now leave this simple structure behind, when starting to test the policies? I don’t see the benefit.

What might look nice with the pundit-matchers approach is that one can use shared examples extensively, as the differences between users/roles to be tests may be subtle. pundit/rspec has less use for shared examples in my opinion, as one could group the shared examples using the permissions block. For example the permissions :login_with_password?, :login_with_sso?-block would run the tests against both actions. Given that, the “advantage” of pundit-matchers is not really an advantage, but a sign of how scattered the test scenarios are.

What I dislike of the pundit/rspec approach I implemented is the line # rubocop:disable RSpec/MultipleMemoizedHelpers. This is due to the fact, that I am defining each user/role in a separate let-block right in the beginning of the file and I am reusing them extensively. I don’t see an alternative, but to disable that cop for this file.

My recommendation

Given the above, I think the pundit/rspec approach is better suited for my use case, where test cases can be quite complex.

However, I will try to use the pundit-matchers approach for simple cases, where the pundit/rspec approach would be too verbose.

I am happy to learn about your perspective and experience with testing Pundit policies. Please leave a comment on Mastodon or Twitter.

References

Example output of the pundit/rspec approach

AccountPolicy
  AccountPolicy::Scope
    restricts the scope to the user's account
  login_with_password? und login_with_sso?
    is expected not to permit #<User id: 2, email: "emely.fehrig@bechtelar.test", created_at: "2023-06-19 21:30:41.298959665 +0000"...id: 2, first_name: "Emely", last_name: "Fehrig", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 2, name: "Heinrich, Ritosek und Gunther", subdomain: "customer-2", created_at: "2023-06...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 3, email: "patrice_salzmann@zboncak.example", created_at: "2023-06-19 21:30:41.339075511 +...3, first_name: "Patrice", last_name: "Salzmann", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 3, name: "Götz-Pinnock", subdomain: "customer-3", created_at: "2023-06-19 21:30:41.3161...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 4, email: "woytkowska_semih@conn-conroy.test", created_at: "2023-06-19 21:30:41.518506360 ...4, first_name: "Semih", last_name: "Woytkowska", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 4, name: "Schildhauer-Rach", subdomain: "customer-4", created_at: "2023-06-19 21:30:41....me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
  login_with_password?
    when user is a guest
      grants access if SSO feature is disabled
      when SSO feature is enabled
        grants access if sso_disable_username_password preference is false
        when sso_disable_username_password preference is true
          denies access if subdomain present
          grants access if subdomain blank
  login_with_sso?
    when user is a guest
      denies access if SSO feature is disabled
      when SSO feature is enabled
        denies access if subdomain is blank
        grants access if subdomain is present
  show?
    is expected not to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 12, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 12, name: "Hermann-Lippe", subdomain: "customer-12", created_at: "2023-06-19 21:30:41.8...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected to permit #<User id: 5, email: "beh.helene@cronin.example", created_at: "2023-06-19 21:30:41.893439700 +0000", ..._id: 13, first_name: "Helene", last_name: "Beh", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 13, name: "Oppong GmbH & Co. KG", subdomain: "customer-13", created_at: "2023-06-19 21:...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected to permit #<User id: 6, email: "leimbach.joelina@padberg-marquardt.test", created_at: "2023-06-19 21:30:41.9419...4, first_name: "Joelina", last_name: "Leimbach", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 14, name: "Malkus, Jess und Storl", subdomain: "customer-14", created_at: "2023-06-19 2...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected to permit #<User id: 7, email: "bauer_natalia@larkin-bergnaum.test", created_at: "2023-06-19 21:30:42.014043747...: 15, first_name: "Natalia", last_name: "Bauer", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 15, name: "Kette-Ruch", subdomain: "customer-15", created_at: "2023-06-19 21:30:41.9878...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
  show? und update?
    when users are not member of the account
      is expected not to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 16, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 17, name: "Sarvari-Moedl", subdomain: "customer-17", created_at: "2023-06-19 21:30:42.0...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
      is expected not to permit #<User id: 8, email: "sky.boerner@frami-cummerata.example", created_at: "2023-06-19 21:30:42.12501239..._id: 18, first_name: "Sky", last_name: "Börner", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 19, name: "Boruschewski, Hildenbrand und Leipold", subdomain: "customer-19", created_at...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
      is expected not to permit #<User id: 9, email: "tamina.tschiers@stoltenberg.example", created_at: "2023-06-19 21:30:42.18251206...20, first_name: "Tamina", last_name: "Tschiers", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 21, name: "Roos-Norris", subdomain: "customer-21", created_at: "2023-06-19 21:30:42.210...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
      is expected not to permit #<User id: 10, email: "polizzi.ashley@anderson-funk.test", created_at: "2023-06-19 21:30:42.263507822... 22, first_name: "Ashley", last_name: "Polizzi", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 23, name: "Siebel OHG", subdomain: "customer-23", created_at: "2023-06-19 21:30:42.2960...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
  create?
    is expected not to permit #<User id: 11, email: "angela.dingelstedt@kiehn-rosenbaum.example", created_at: "2023-06-19 21:30:42.... first_name: "Angela", last_name: "Dingelstedt", employee_number: nil, phone_number: nil, uuid: nil> and Account(id: integer, name: string, subdomain: citext, created_at: datetime, updated_at: datetime, can...brand_data: jsonb, preferences: jsonb, stripe_customer_data: jsonb, stripe_subscription_data: jsonb)
    is expected not to permit #<User id: 12, email: "sammy_luebke@schmidt.example", created_at: "2023-06-19 21:30:42.395901302 +000...id: 25, first_name: "Sammy", last_name: "Lübke", employee_number: nil, phone_number: nil, uuid: nil> and Account(id: integer, name: string, subdomain: citext, created_at: datetime, updated_at: datetime, can...brand_data: jsonb, preferences: jsonb, stripe_customer_data: jsonb, stripe_subscription_data: jsonb)
    is expected not to permit #<User id: 13, email: "carina.boehm@spencer-satterfield.example", created_at: "2023-06-19 21:30:42.45...id: 26, first_name: "Carina", last_name: "Böhm", employee_number: nil, phone_number: nil, uuid: nil> and Account(id: integer, name: string, subdomain: citext, created_at: datetime, updated_at: datetime, can...brand_data: jsonb, preferences: jsonb, stripe_customer_data: jsonb, stripe_subscription_data: jsonb)
    when Config.bc_disable_account_new is true
      is expected not to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 27, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and Account(id: integer, name: string, subdomain: citext, created_at: datetime, updated_at: datetime, can...brand_data: jsonb, preferences: jsonb, stripe_customer_data: jsonb, stripe_subscription_data: jsonb)
    when Config.bc_disable_account_new is false
      is expected to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 28, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and Account(id: integer, name: string, subdomain: citext, created_at: datetime, updated_at: datetime, can...brand_data: jsonb, preferences: jsonb, stripe_customer_data: jsonb, stripe_subscription_data: jsonb)
  update?
    is expected not to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 29, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 29, name: "Sack AG", subdomain: "customer-29", created_at: "2023-06-19 21:30:42.6172760...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 14, email: "zach_karim@stehr-dare.test", created_at: "2023-06-19 21:30:42.681501412 +0000"..._id: 30, first_name: "Karim", last_name: "Zach", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 30, name: "Knoll-Hördt", subdomain: "customer-30", created_at: "2023-06-19 21:30:42.654...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected to permit #<User id: 15, email: "wiese_cheyenne@howe.example", created_at: "2023-06-19 21:30:42.746309119 +0000... 31, first_name: "Cheyenne", last_name: "Wiese", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 31, name: "Ziegler AG", subdomain: "customer-31", created_at: "2023-06-19 21:30:42.7140...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 16, email: "aliyah_frantz@bosco.example", created_at: "2023-06-19 21:30:42.816685330 +0000...: 32, first_name: "Aliyah", last_name: "Frantz", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 32, name: "Mächtig-Kahlert", subdomain: "customer-32", created_at: "2023-06-19 21:30:42...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
  destroy?
    is expected not to permit #<User id: nil, email: "", created_at: nil, updated_at: nil, locale: nil, account_id: 33, first_name: nil, last_name: nil, employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 33, name: "Strieder, Birkemeyer und Strausa", subdomain: "customer-33", created_at: "20...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 17, email: "moeldner_leann@wisoky.test", created_at: "2023-06-19 21:30:42.907541939 +0000"...: 34, first_name: "Leann", last_name: "Möldner", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 34, name: "Kleiss, Brix und Förster", subdomain: "customer-34", created_at: "2023-06-19...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 18, email: "bolm_ina@muller-lebsack.test", created_at: "2023-06-19 21:30:42.955454805 +000...nt_id: 35, first_name: "Ina", last_name: "Bolm", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 35, name: "Dressler KG", subdomain: "customer-35", created_at: "2023-06-19 21:30:42.928...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
    is expected not to permit #<User id: 19, email: "ahlke_elia@conn.test", created_at: "2023-06-19 21:30:43.031144494 +0000", upda..._id: 36, first_name: "Elia", last_name: "Ahlke", employee_number: nil, phone_number: nil, uuid: nil> and #<Account id: 36, name: "Berner, Moguenara und Allgeyer", subdomain: "customer-36", created_at: "2023...me_password: false, welcome_message: nil>, stripe_customer_data: nil, stripe_subscription_data: nil>
  #permitted_attributes_for_create
    with guests
      with custom subdomain feature enabled
        is expected to include :subdomain
        is expected to contain exactly :name, :subdomain, and {:subscription_attributes=>[], :users_attributes=>[]}
      with custom subdomain feature disabled
        is expected not to include :subdomain
        is expected to contain exactly :name and {:subscription_attributes=>[], :users_attributes=>[]}
    with users
      is expected to be empty
    with librarians
      is expected to be empty
    with admins
      is expected to be empty
  #permitted_attributes_for_update
    with guests
      is expected to be empty
    with users
      is expected to be empty
    with librarians
      is expected to be empty
    with admins
      is expected to contain exactly :name, :brand, :remove_brand, and {:preferences=>[:my_doubled_attributes]}

Example output of the pundit/rspec approach

AccountPolicy
  with guests
    is expected to permit [:create, :new]
    is expected to forbid [:edit, :update]
    is expected to forbid [:show]
    is expected to forbid [:destroy]
    is expected to permit [:login_with_password]
    is expected to forbid [:login_with_sso]
    when Config.bc_disable_account_new is true
      is expected to forbid [:create, :new]
    with SSO feature disabled
      is expected to permit [:login_with_password]
      is expected to forbid [:login_with_sso]
    with SSO feature enabled
      when sso_disable_username_password preference is true
        with subdomain present
          is expected to forbid [:login_with_password]
          is expected to permit [:login_with_sso]
        with subdomain blank
          is expected to permit [:login_with_password]
          is expected to forbid [:login_with_sso]
      when sso_disable_username_password preference is false
        is expected to permit [:login_with_password]
        is expected to permit [:login_with_sso]
    behaves like scope limited to current account
      includes only current account in resolved scope
  with users
    behaves like a logged in user
      is expected to forbid [:create, :new]
      is expected to forbid [:edit, :update]
      is expected to permit [:show]
      is expected to forbid [:destroy]
      is expected to forbid [:login_with_password]
      is expected to forbid [:login_with_sso]
      when accessing a different account
        is expected to forbid all actions
      behaves like scope limited to current account
        includes only current account in resolved scope
  with librarians
    behaves like a logged in user
      is expected to forbid [:create, :new]
      is expected to forbid [:edit, :update]
      is expected to permit [:show]
      is expected to forbid [:destroy]
      is expected to forbid [:login_with_password]
      is expected to forbid [:login_with_sso]
      when accessing a different account
        is expected to forbid all actions
      behaves like scope limited to current account
        includes only current account in resolved scope
  with admins
    is expected to forbid [:create, :new]
    is expected to permit [:edit, :update]
    is expected to permit [:show]
    is expected to forbid [:destroy]
    is expected to forbid [:login_with_password]
    is expected to forbid [:login_with_sso]
    when accessing a different account
      is expected to forbid all actions
    behaves like scope limited to current account
      includes only current account in resolved scope
  permitted attributes for create
    with guests
      with custom subdomain feature enabled
        is expected to include :subdomain
        is expected to contain exactly :name, :subdomain, and {:subscription_attributes=>[], :users_attributes=>[]}
      with custom subdomain feature disabled
        is expected not to include :subdomain
        is expected to contain exactly :name and {:subscription_attributes=>[], :users_attributes=>[]}
    with users
      is expected to be empty
    with librarians
      is expected to be empty
    with admins
      is expected to be empty
  permitted attributes for update
    with guests
      is expected to be empty
    with users
      is expected to be empty
    with librarians
      is expected to be empty
    with admins
      is expected to contain exactly :name, :brand, :remove_brand, and {:preferences=>[:my_doubled_attributes]}