diff --git a/lib/shoulda/matchers/active_record.rb b/lib/shoulda/matchers/active_record.rb index 7ed16585e..581663b6e 100644 --- a/lib/shoulda/matchers/active_record.rb +++ b/lib/shoulda/matchers/active_record.rb @@ -13,6 +13,7 @@ require "shoulda/matchers/active_record/have_db_column_matcher" require "shoulda/matchers/active_record/have_db_index_matcher" require "shoulda/matchers/active_record/have_readonly_attribute_matcher" +require "shoulda/matchers/active_record/have_secure_token_matcher" require "shoulda/matchers/active_record/serialize_matcher" require "shoulda/matchers/active_record/accept_nested_attributes_for_matcher" require "shoulda/matchers/active_record/define_enum_for_matcher" diff --git a/lib/shoulda/matchers/active_record/have_secure_token_matcher.rb b/lib/shoulda/matchers/active_record/have_secure_token_matcher.rb new file mode 100644 index 000000000..693c7d8be --- /dev/null +++ b/lib/shoulda/matchers/active_record/have_secure_token_matcher.rb @@ -0,0 +1,111 @@ +module Shoulda + module Matchers + module ActiveRecord + # The `have_secure_token` matcher tests usage of the + # `has_secure_token` macro. + # + # #### Example + # + # class User < ActiveRecord + # attr_accessor :token + # attr_accessor :auth_token + # + # has_secure_token + # has_secure_token :auth_token + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should have_secure_token } + # it { should have_secure_token(:auth_token) } + # end + # + # # Minitest (Shoulda) + # class UserTest < ActiveSupport::TestCase + # should have_secure_token + # should have_secure_token(:auth_token) + # end + # + # @return [HaveSecureToken] + # + + # rubocop:disable Style/PredicateName + def have_secure_token(token_attribute = :token) + HaveSecureTokenMatcher.new(token_attribute) + end + # rubocop:enable Style/PredicateName + + # @private + class HaveSecureTokenMatcher + attr_reader :token_attribute + + def initialize(token_attribute) + @token_attribute = token_attribute + end + + def description + "have :#{token_attribute} as a secure token" + end + + def failure_message + return if !@errors + "Expected #{@subject.class} to #{description} but the following " \ + "errors were found: #{@errors.join(', ')}" + end + + def failure_message_when_negated + return if !@errors + "Did not expect #{@subject.class} to have secure token " \ + ":#{token_attribute}" + end + + def matches?(subject) + @subject = subject + @errors = run_checks + @errors.empty? + end + + private + + def run_checks + @errors = [] + if !has_expected_instance_methods? + @errors << 'missing expected class and instance methods' + end + if !has_expected_db_column? + @errors << "missing correct column #{token_attribute}:string" + end + if !has_expected_db_index? + @errors << "missing unique index for #{table_and_column}" + end + @errors + end + + def has_expected_instance_methods? + @subject.respond_to?(token_attribute.to_s) && + @subject.respond_to?("#{token_attribute}=") && + @subject.respond_to?("regenerate_#{token_attribute}") && + @subject.class.respond_to?(:generate_unique_secure_token) + end + + def has_expected_db_column? + matcher = HaveDbColumnMatcher.new(token_attribute).of_type(:string) + matcher.matches?(@subject) + end + + def has_expected_db_index? + matcher = HaveDbIndexMatcher.new(token_attribute).unique(true) + matcher.matches?(@subject) + end + + def table_and_column + "#{table_name}.#{token_attribute}" + end + + def table_name + @subject.class.table_name + end + end + end + end +end diff --git a/lib/shoulda/matchers/util.rb b/lib/shoulda/matchers/util.rb index bc3687530..36ac126e1 100644 --- a/lib/shoulda/matchers/util.rb +++ b/lib/shoulda/matchers/util.rb @@ -27,6 +27,7 @@ def self.safe_constantize(camel_cased_word) end def self.indent(string, width) + return if !string indentation = ' ' * width string.split(/[\n\r]/).map { |line| indentation + line }.join("\n") end diff --git a/spec/support/unit/helpers/active_record_versions.rb b/spec/support/unit/helpers/active_record_versions.rb index cf66604ba..1dca20758 100644 --- a/spec/support/unit/helpers/active_record_versions.rb +++ b/spec/support/unit/helpers/active_record_versions.rb @@ -17,6 +17,10 @@ def active_record_supports_has_secure_password? active_record_version >= 3.1 end + def active_record_supports_has_secure_token? + active_record_version >= 5.0 + end + def active_record_supports_array_columns? active_record_version > 4.2 end diff --git a/spec/unit/shoulda/matchers/active_record/have_secure_token_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/have_secure_token_matcher_spec.rb new file mode 100644 index 000000000..bb2ac9ee8 --- /dev/null +++ b/spec/unit/shoulda/matchers/active_record/have_secure_token_matcher_spec.rb @@ -0,0 +1,169 @@ +require 'unit_spec_helper' + +# rubocop:disable Metrics/BlockLength +describe Shoulda::Matchers::ActiveRecord::HaveSecureTokenMatcher, + type: :model do + + if active_record_supports_has_secure_token? + describe '#description' do + it 'returns the message including the name of the default column' do + matcher = have_secure_token + expect(matcher.description). + to eq('have :token as a secure token') + end + + it 'returns the message including the name of a provided column' do + matcher = have_secure_token(:special_token) + expect(matcher.description). + to eq('have :special_token as a secure token') + end + end + + it 'matches when the subject configures has_secure_token with the db' do + create_table(:users) do |t| + t.string :token + t.index :token, unique: true + end + + valid_model = define_model_class(:User) { has_secure_token } + + expect(valid_model.new).to have_secure_token + end + + it 'matches when the subject configures has_secure_token with the db for ' \ + 'a custom attribute' do + create_table(:users) do |t| + t.string :auth_token + t.index :auth_token, unique: true + end + + valid_model = define_model_class(:User) { has_secure_token(:auth_token) } + expect(valid_model.new).to have_secure_token(:auth_token) + end + + it 'does not match when missing an token index' do + create_table(:users) do |t| + t.string :token + end + + invalid_model = define_model_class(:User) { has_secure_token } + expected_message = + 'Expected User to have :token as a secure token but the following ' \ + 'errors were found: missing unique index for users.token' + + aggregate_failures do + expect(invalid_model.new).not_to have_secure_token + expect { expect(invalid_model.new).to have_secure_token }. + to fail_with_message(expected_message) + end + end + + it 'does not match when missing a token column' do + create_table(:users) + invalid_model = define_model_class(:User) { has_secure_token } + + expected_message = + 'Expected User to have :token as a secure token but the following ' \ + 'errors were found: missing expected class and instance methods, ' \ + 'missing correct column token:string, missing unique index for ' \ + 'users.token' + + aggregate_failures do + expect(invalid_model.new).not_to have_secure_token + expect { expect(invalid_model.new).to have_secure_token }. + to fail_with_message(expected_message) + end + end + + it 'does not match when when lacking has_secure_token' do + create_table(:users) do |t| + t.string :token + t.index :token + end + + invalid_model = define_model_class(:User) + + expected_message = + 'Expected User to have :token as a secure token but the following ' \ + 'errors were found: missing expected class and instance methods, ' \ + 'missing unique index for users.token' + + aggregate_failures do + expect(invalid_model.new).not_to have_secure_token + expect { expect(invalid_model.new).to have_secure_token }. + to fail_with_message(expected_message) + end + end + + it 'does not match when missing an index for a custom attribute' do + create_table(:users) do |t| + t.string :auth_token + end + + invalid_model = define_model_class(:User) do + has_secure_token(:auth_token) + end + + expected_message = + 'Expected User to have :auth_token as a secure token but the ' \ + 'following errors were found: missing unique index for ' \ + 'users.auth_token' + + aggregate_failures do + expect(invalid_model.new).not_to have_secure_token(:auth_token) + expect { expect(invalid_model.new).to have_secure_token(:auth_token) }. + to fail_with_message(expected_message) + end + end + + it 'does not match when missing a column for a custom attribute' do + create_table(:users) + invalid_model = define_model_class(:User) do + has_secure_token(:auth_token) + end + + expected_message = + 'Expected User to have :auth_token as a secure token but the ' \ + 'following errors were found: missing expected class and instance ' \ + 'methods, missing correct column auth_token:string, missing unique ' \ + 'index for users.auth_token' + + aggregate_failures do + expect(invalid_model.new).not_to have_secure_token(:auth_token) + expect { expect(invalid_model.new).to have_secure_token(:auth_token) }. + to fail_with_message(expected_message) + end + end + + it 'does not match when when lacking has_secure_token for the attribute' do + create_table(:users) do |t| + t.string :auth_token + t.index :auth_token, unique: true + end + + invalid_model = define_model_class(:User) + expected_message = + 'Expected User to have :auth_token as a secure token but the ' \ + 'following errors were found: missing expected class and instance ' \ + 'methods' + + aggregate_failures do + expect(invalid_model.new).not_to have_secure_token(:auth_token) + expect { expect(invalid_model.new).to have_secure_token(:auth_token) }. + to fail_with_message(expected_message) + end + end + + it 'fails with the appropriate message when negated' do + create_table(:users) do |t| + t.string :token + t.index :token, unique: true + end + + valid_model = define_model_class(:User) { has_secure_token } + + expect { expect(valid_model.new).not_to have_secure_token }. + to fail_with_message('Did not expect User to have secure token :token') + end + end +end