From 2a2b06276feca012010e02ab92a2c0735705fc05 Mon Sep 17 00:00:00 2001 From: theforestvn88 <54012293+theforestvn88@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:01:15 +0700 Subject: [PATCH] feat: Add `encrypt_matcher` to test usage of the encrypts method (#1581) --- README.md | 2 + lib/shoulda/matchers/active_record.rb | 1 + .../matchers/active_record/encrypt_matcher.rb | 174 +++++++++++++++ .../active_record/encrypt_matcher_spec.rb | 208 ++++++++++++++++++ 4 files changed, 385 insertions(+) create mode 100644 lib/shoulda/matchers/active_record/encrypt_matcher.rb create mode 100644 spec/unit/shoulda/matchers/active_record/encrypt_matcher_spec.rb diff --git a/README.md b/README.md index 81982f3ea..786b7fed5 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,8 @@ about any of them, make sure to [consult the documentation][rubydocs]! tests usage of `validates_uniqueness_of`. * **[normalize](lib/shoulda/matchers/active_record/normalize_matcher.rb)** tests usage of the `normalize` macro +* **[encrypt](lib/shoulda/matchers/active_record/encrypt_matcher.rb)** + tests usage of the `encrypts` macro. ### ActionController matchers diff --git a/lib/shoulda/matchers/active_record.rb b/lib/shoulda/matchers/active_record.rb index dab0be1ff..1b1e2d69b 100644 --- a/lib/shoulda/matchers/active_record.rb +++ b/lib/shoulda/matchers/active_record.rb @@ -25,6 +25,7 @@ require 'shoulda/matchers/active_record/validate_uniqueness_of_matcher' require 'shoulda/matchers/active_record/have_attached_matcher' require 'shoulda/matchers/active_record/normalize_matcher' +require 'shoulda/matchers/active_record/encrypt_matcher' module Shoulda module Matchers diff --git a/lib/shoulda/matchers/active_record/encrypt_matcher.rb b/lib/shoulda/matchers/active_record/encrypt_matcher.rb new file mode 100644 index 000000000..fb165192c --- /dev/null +++ b/lib/shoulda/matchers/active_record/encrypt_matcher.rb @@ -0,0 +1,174 @@ +module Shoulda + module Matchers + module ActiveRecord + # The `encrypt` matcher tests usage of the + # `encrypts` macro (Rails 7+ only). + # + # class Survey < ActiveRecord::Base + # encrypts :access_code + # end + # + # # RSpec + # RSpec.describe Survey, type: :model do + # it { should encrypt(:access_code) } + # end + # + # # Minitest (Shoulda) + # class SurveyTest < ActiveSupport::TestCase + # should encrypt(:access_code) + # end + # + # #### Qualifiers + # + # ##### deterministic + # + # class Survey < ActiveRecord::Base + # encrypts :access_code, deterministic: true + # end + # + # # RSpec + # RSpec.describe Survey, type: :model do + # it { should encrypt(:access_code).deterministic(true) } + # end + # + # # Minitest (Shoulda) + # class SurveyTest < ActiveSupport::TestCase + # should encrypt(:access_code).deterministic(true) + # end + # + # ##### downcase + # + # class Survey < ActiveRecord::Base + # encrypts :access_code, downcase: true + # end + # + # # RSpec + # RSpec.describe Survey, type: :model do + # it { should encrypt(:access_code).downcase(true) } + # end + # + # # Minitest (Shoulda) + # class SurveyTest < ActiveSupport::TestCase + # should encrypt(:access_code).downcase(true) + # end + # + # ##### ignore_case + # + # class Survey < ActiveRecord::Base + # encrypts :access_code, deterministic: true, ignore_case: true + # end + # + # # RSpec + # RSpec.describe Survey, type: :model do + # it { should encrypt(:access_code).ignore_case(true) } + # end + # + # # Minitest (Shoulda) + # class SurveyTest < ActiveSupport::TestCase + # should encrypt(:access_code).ignore_case(true) + # end + # + # @return [EncryptMatcher] + # + def encrypt(value) + EncryptMatcher.new(value) + end + + # @private + class EncryptMatcher + def initialize(attribute) + @attribute = attribute.to_sym + @options = {} + end + + attr_reader :failure_message, :failure_message_when_negated + + def deterministic(deterministic) + with_option(:deterministic, deterministic) + end + + def downcase(downcase) + with_option(:downcase, downcase) + end + + def ignore_case(ignore_case) + with_option(:ignore_case, ignore_case) + end + + def matches?(subject) + @subject = subject + result = encrypted_attributes_included? && + options_correct?( + :deterministic, + :downcase, + :ignore_case, + ) + + if result + @failure_message_when_negated = "Did not expect to #{description} of #{class_name}" + if @options.present? + @failure_message_when_negated += " +using " + @failure_message_when_negated += @options.map { |opt, expected| + ":#{opt} option as ‹#{expected}›" + }.join(' and +') + end + + @failure_message_when_negated += ", +but it did" + end + + result + end + + def description + "encrypt :#{@attribute}" + end + + private + + def encrypted_attributes_included? + if encrypted_attributes.include?(@attribute) + true + else + @failure_message = "Expected to #{description} of #{class_name}, but it did not" + false + end + end + + def with_option(option_name, value) + @options[option_name] = value + self + end + + def options_correct?(*opts) + opts.all? do |opt| + next true unless @options.key?(opt) + + expected = @options[opt] + actual = encrypted_attribute_scheme.send("#{opt}?") + next true if expected == actual + + @failure_message = "Expected to #{description} of #{class_name} using :#{opt} option +as ‹#{expected}›, but got ‹#{actual}›" + + false + end + end + + def encrypted_attributes + @_encrypted_attributes ||= @subject.class.encrypted_attributes || [] + end + + def encrypted_attribute_scheme + @subject.class.type_for_attribute(@attribute).scheme + end + + def class_name + @subject.class.name + end + end + end + end +end diff --git a/spec/unit/shoulda/matchers/active_record/encrypt_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/encrypt_matcher_spec.rb new file mode 100644 index 000000000..3530a1da7 --- /dev/null +++ b/spec/unit/shoulda/matchers/active_record/encrypt_matcher_spec.rb @@ -0,0 +1,208 @@ +require 'unit_spec_helper' + +describe Shoulda::Matchers::ActiveRecord::EncryptMatcher, type: :model do + if rails_version >= 7.0 + context 'a encrypt attribute' do + it 'accepts' do + expect(with_encrypt_attr).to encrypt(:attr) + end + + it 'rejects when used in the negative' do + assertion = lambda do + expect(with_encrypt_attr).not_to encrypt(:attr) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Did not expect to encrypt :attr of Example, +but it did + MESSAGE + end + end + + context 'an attribute that is not part of the encrypted-attributes set' do + it 'rejects being encrypted' do + model = define_model :example, attr: :string, other: :string do + encrypts :attr + end.new + + expect(model).not_to encrypt(:other) + end + end + + context 'an attribute on a class with no encrypt attributes' do + it 'rejects being encrypted' do + expect(define_model(:example, attr: :string).new). + not_to encrypt(:attr) + end + + it 'assigns a failure message' do + model = define_model(:example, attr: :string).new + + assertion = lambda do + expect(model).to encrypt(:attr) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example, but it did not + MESSAGE + end + end + + context 'deterministic' do + it 'default value is false' do + expect(with_encrypt_attr).to encrypt(:attr).deterministic(false) + end + + it 'accepts a valid truthy value' do + expect(with_encrypt_attr(deterministic: true)).to encrypt(:attr).deterministic(true) + end + + it 'accepts a valid falsey value' do + expect(with_encrypt_attr(deterministic: false)).to encrypt(:attr).deterministic(false) + end + + it 'rejects an invalid truthy value' do + assertion = lambda do + expect(with_encrypt_attr(deterministic: true)).to encrypt(:attr).deterministic(false) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example using :deterministic option +as ‹false›, but got ‹true› + MESSAGE + end + + it 'rejects an invalid falsey value' do + assertion = lambda do + expect(with_encrypt_attr).to encrypt(:attr).deterministic(true) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example using :deterministic option +as ‹true›, but got ‹false› + MESSAGE + end + + it 'rejects when used in the negative' do + assertion = lambda do + expect(with_encrypt_attr(deterministic: true)).not_to encrypt(:attr).deterministic(true) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Did not expect to encrypt :attr of Example +using :deterministic option as ‹true›, +but it did + MESSAGE + end + end + + context 'downcase' do + it 'default value is false' do + expect(with_encrypt_attr).to encrypt(:attr).downcase(false) + end + + it 'accepts a valid truthy value' do + expect(with_encrypt_attr(downcase: true)).to encrypt(:attr).downcase(true) + end + + it 'accepts a valid falsey value' do + expect(with_encrypt_attr(downcase: false)).to encrypt(:attr).downcase(false) + end + + it 'rejects an invalid truthy value' do + assertion = lambda do + expect(with_encrypt_attr(downcase: true)).to encrypt(:attr).downcase(false) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example using :downcase option +as ‹false›, but got ‹true› + MESSAGE + end + + it 'rejects an invalid falsey value' do + assertion = lambda do + expect(with_encrypt_attr).to encrypt(:attr).downcase(true) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example using :downcase option +as ‹true›, but got ‹false› + MESSAGE + end + + it 'rejects when used in the negative' do + assertion = lambda do + expect(with_encrypt_attr(downcase: true)).not_to encrypt(:attr).downcase(true) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Did not expect to encrypt :attr of Example +using :downcase option as ‹true›, +but it did + MESSAGE + end + end + + context 'ignore_case' do + it 'default value is false' do + expect(with_encrypt_attr).to encrypt(:attr).ignore_case(false) + end + + it 'accepts a valid truthy value' do + expect(with_encrypt_ignore_case_attr).to encrypt(:attr).ignore_case(true) + end + + it 'accepts a valid falsey value' do + expect(with_encrypt_attr(ignore_case: false)).to encrypt(:attr).ignore_case(false) + end + + it 'rejects an invalid truthy value' do + assertion = lambda do + expect(with_encrypt_ignore_case_attr).to encrypt(:attr).ignore_case(false) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example using :ignore_case option +as ‹false›, but got ‹true› + MESSAGE + end + + it 'rejects an invalid falsey value' do + assertion = lambda do + expect(with_encrypt_attr(deterministic: true, ignore_case: false)).to encrypt(:attr).ignore_case(true) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected to encrypt :attr of Example using :ignore_case option +as ‹true›, but got ‹false› + MESSAGE + end + + it 'rejects when used in the negative' do + assertion = lambda do + expect(with_encrypt_ignore_case_attr).not_to encrypt(:attr).deterministic(true).ignore_case(true) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Did not expect to encrypt :attr of Example +using :deterministic option as ‹true› and +:ignore_case option as ‹true›, +but it did + MESSAGE + end + end + + def with_encrypt_attr(**options) + define_model :example, attr: :string do + encrypts :attr, **options + end.new + end + + def with_encrypt_ignore_case_attr(**options) + define_model :example, attr: :string, original_attr: :string do + encrypts :attr, deterministic: true, ignore_case: true, **options + end.new + end + end +end