Writing and Testing a Custom Robocop Cop

It’s great to solve a problem – but it’s even better to keep it from coming back. As we solve issues in our code base, we often ponder how to completely exclude that classification of the problem from the code base. Sometimes we reach out to RoboCop to help the police with certain patterns. It also helps to document the core issue and educate teammates about why these patterns are undesirable.

The Rubocop is more than just a liter. It is highly extensible and allows you to write custom cops to enforce specific behavior. These cops can be used to create better code practices, prevent bad patterns from penetrating the legacy code base, and provide training for other engineers. But it can be difficult to know how to make a new cop and whether it will work in the long run.

We can write unit tests to ensure the success of our custom cops, just like we do with any application code.
Let’s look at this with an example of how testing can be done.

custom police test

with ah! engineering team, each model has one account_id The attribute exists and for security reasons, we never want it to be set via mass-assignment. To avoid this, we want to prevent certain attributes from being added to attr_accessible.

# bad
class Foo
  attr_accessible :name, :account_id
end
Foo.create(account_id: 1, name: "foo")
# good
class Foo
  attr_accessible :name
end
foo = Foo.new(name: "foo")
foo.account_id = 1
foo.save
enter fullscreen mode

exit fullscreen mode

We have a custom cop that parses the arguments to that method and will error out if a protected attribute is present. The custom cop we have looks something like this:

class RuboCop::Cop::ProtectedAttrAccessibleFields < RuboCop::Cop::Cop
  # We can define a list of attributes we want to protect
  PROTECTED_ATTRIBUTES = [
    :account_id,
  ].freeze
  # We can define an error message that is displayed when an offense is detected.
  # This can be helpful to communicate information back to other engineers
  ERROR_MESSAGE = <<~ERROR.freeze
    Only permit attributes that are safe to be completely user controlled. Typically any *_id field could be problematic.
    Instead perform direct assignment of the field after doing a scoped lookup. This is the safest way to handle user input.
    Some fields such as #{PROTECTED_ATTRIBUTES.inspect} should never be used as part of attr_accessible.
  ERROR
  # We want to examine method calls. Particularly those that are calling the attr_accessible method
  # and also have arguments we care about
  def on_send(node)
    if receiver_attr_accessible?(node) && protected_arguments?(node)
      # If we do detect an attr_accessible call with arguments we care about, we can record an offense
      add_offense(node, message: ERROR_MESSAGE)
    end
  end
  private
  def receiver_attr_accessible?(node)
    node.method_name == :attr_accessible
  end
  def protected_arguments?(node)
    node.arguments.any? do |argument|
      if argument.sym_type? || argument.str_type?
        PROTECTED_ATTRIBUTES.include?(argument.value.to_sym)
      end
    end
  end
end
enter fullscreen mode

exit fullscreen mode

This is custom cop trick. Adding a test for this ensures that it won’t break in the future when we update or expand the functionality of Robocop. To write a test, we need to understand how Custom Police is set up and run.

call a customs cop immediately

RuboCop::Cop::Cop inherited from RuboCop::Cop::Base And it allows instantiation without any arguments. So it turns out that this is nothing special – creating a new instance of our cop is really as simple as: RuboCop::Cop::ProtectedAttrAccessibleFields.new

If the cop requires some kind of configuration, it can be done for example with a . can be passed through RuboCop::Config Thing. RuboCop::Config Takes two arguments. Rubocop can provide configuration via YML files. You can use the first argument of RuboCop::Config To pass this configuration with different values ​​from test. The second argument is the path to the loaded YML file, which can be ignored in tests.

config = RuboCop::Config.new({ RuboCop::Cop::ProtectedAttrAccessibleFields.badge.to_s => {} }, "https://dev.to/")
cop = RuboCop::Cop::ProtectedAttrAccessibleFields.new(config)
enter fullscreen mode

exit fullscreen mode

process, execute, check

As it turns out, there is a method available, RuboCop::Cop::Base#parse which accepts a string as input and will return something that the cop can process.

This allows us to do something like this:

source = <<~CODE
  attr_accessible :account_id
CODE
processed_source = cop.parse(source)
enter fullscreen mode

exit fullscreen mode

There is a class from within Rubocop, RuboCop::Cop::Commissioner , which is responsible for taking an inventory of the police and using them to examine the processed source code. We can use this method to run our cop.

commissioner = RuboCop::Cop::Commissioner.new([cop])
investigation_report = commissioner.investigate(processed_source)
enter fullscreen mode

exit fullscreen mode

RuboCop::Cop::Commissioner#investigate The method will return an instance of RuboCop::Cop::Commissioner::InvestigationReport which is a simple struct class containing a list of crimes that have been registered.

Put it all together

We end up with a test file that looks something like this:

describe RuboCop::Cop::ProtectedAttrAccessibleFields do
  let(:config) { RuboCop::Config.new({ described_class.badge.to_s => {} }, "https://dev.to/") }
  let(:cop) { described_class.new(config) }
  let(:commissioner) { RuboCop::Cop::Commissioner.new([cop]) }
  it "records an offense if we use allow account_id as a string" do
    source = <<~CODE
      attr_accessible :foo, 'account_id'
    CODE
    investigation_report = commissioner.investigate(cop.parse(source))
    expect(investigation_report.offenses).to_not be_blank
    expect(investigation_report.offenses.first.message).to eql described_class::ERROR_MESSAGE
  end
  it "records an offense if we use allow account_id as symbol" do
    source = <<~CODE
      attr_accessible :foo, :account_id
    CODE
    investigation_report = commissioner.investigate(cop.parse(source))
    expect(investigation_report.offenses).to_not be_blank
    expect(investigation_report.offenses.first.message).to eql described_class::ERROR_MESSAGE
  end
  it "doesn't record an offense if no protected attribute is used" do
    source = <<~CODE
      attr_accessible :foo
    CODE
    investigation_report = commissioner.investigate(cop.parse(source))
    expect(investigation_report.offenses).to be_blank
  end
end
enter fullscreen mode

exit fullscreen mode

Now that we know how to write tests, we can use them as a starting point for building new cops, extending existing cops, and ensuring that our application grows and evolves. Yes, keep things working. These small investments in project-specific policing can become a major investment in the future health of projects.

Sign up for a free trial of Aha! develop

Ahh! Evolution is a fully expandable agile development tool. Prioritize backlogs, estimate work and plan sprints. If you’re interested in an integrated product development approach, Aha! Roadmap and Ah! develop together. Sign up for a free 30-day trial or join the live demo to see why more than 5,000 companies rely on our software to build lovely products and are happy to do it.

Leave a Comment