diff --git a/test/test_orm_validates_encrypted_attribute.rb b/test/test_orm_validates_encrypted_attribute.rb new file mode 100644 index 0000000..8f587a8 --- /dev/null +++ b/test/test_orm_validates_encrypted_attribute.rb @@ -0,0 +1,73 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +# Regression coverage for a real bug found while wiring `encrypts` into +# examples/blog's User#email: every validator in Validatable read the raw +# stored value through `self[attribute]` instead of the attribute's own +# reader method. For a plain column that is the same value either way, so +# the bug was invisible until a column had a reader that DIFFERS from raw +# storage - exactly what `encrypts` defines (decrypts on read, and +# deterministic columns re-encrypt on query). Two concrete failures this +# caused before the fix: +# 1. `format`/`length`/`inclusion` validators matched the CIPHERTEXT, +# not the plaintext the user typed - a syntactically valid email was +# rejected as "invalid" (ciphertext never contains '@'). +# 2. `uniqueness` built `where(attribute => ciphertext)`, and +# ConditionNode's deterministic-encryption cast re-encrypts whatever +# it is given - so the already-ciphertext value got encrypted AGAIN, +# never matching the single-encrypted value already on disk. Two +# records with the IDENTICAL plaintext silently passed uniqueness. +class OrmValidatesEncryptedAttributeTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-validates-encrypted-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:contacts, %w[id email]) + Prism::Orm::Encryption.primary_key = 'test-primary-key-do-not-use-in-prod' + + contact_class = Class.new(Prism::Orm::Model) do + table_name :contacts + encrypts :email, deterministic: true + validates :email, presence: true, uniqueness: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ } + end + contact_class.store = @store + Object.const_set(:EncryptedContact, contact_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Orm::Encryption.primary_key = nil + Object.send(:remove_const, :EncryptedContact) if Object.const_defined?(:EncryptedContact) + end + + def test_format_validation_checks_the_decrypted_plaintext_not_the_ciphertext + contact = EncryptedContact.new(email: 'alice@example.com') + assert contact.valid?, "expected a well-formed plaintext email to pass format validation, got: #{contact.errors.full_messages}" + end + + def test_format_validation_still_rejects_a_genuinely_malformed_plaintext + contact = EncryptedContact.new(email: 'not-an-email') + refute contact.valid? + assert_includes contact.errors.full_messages.join, 'invalid' + end + + def test_uniqueness_catches_a_real_duplicate_plaintext_behind_deterministic_encryption + EncryptedContact.create!(email: 'bob@example.com') + duplicate = EncryptedContact.new(email: 'bob@example.com') + refute duplicate.valid?, 'expected the second record with the same plaintext email to fail uniqueness' + assert_includes duplicate.errors.full_messages.join, 'unique' + end + + def test_uniqueness_allows_a_genuinely_different_plaintext + EncryptedContact.create!(email: 'carol@example.com') + other = EncryptedContact.new(email: 'dave@example.com') + assert other.valid?, "expected a different plaintext email to pass uniqueness, got: #{other.errors.full_messages}" + end + + def test_the_stored_column_is_still_ciphertext_despite_validation_seeing_plaintext + contact = EncryptedContact.create!(email: 'erin@example.com') + raw_row = @store.find(:contacts, contact.id) + refute_equal 'erin@example.com', raw_row['email'] + refute_includes raw_row['email'], '@' + end +end diff --git a/test/test_orm_validations_extended.rb b/test/test_orm_validations_extended.rb new file mode 100644 index 0000000..c655778 --- /dev/null +++ b/test/test_orm_validations_extended.rb @@ -0,0 +1,105 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmValidationsExtendedTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-validation-ext-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:products, %w[id name price quantity status]) + + @product_class = Class.new(Prism::Orm::Model) do + table_name :products + validates_numericality_of :price, greater_than: 0 + validates_numericality_of :quantity, only_integer: true, greater_than_or_equal_to: 0 + validates_inclusion_of :status, in: %w[draft published archived] + + validate :name_must_not_be_shouting + + def name_must_not_be_shouting + return unless name && name == name.upcase && name.match?(/[A-Z]/) + errors.add(:name, 'must not be all caps') + end + end + @product_class.store = @store + Object.const_set(:Product, @product_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Product) if Object.const_defined?(:Product) + end + + def test_numericality_rejects_non_numbers + product = Product.new(name: 'widget', price: 'free', quantity: 1, status: 'draft') + refute product.valid? + assert_includes product.errors[:price], 'is not a number' + end + + def test_numericality_enforces_greater_than + product = Product.new(name: 'widget', price: 0, quantity: 1, status: 'draft') + refute product.valid? + assert_includes product.errors[:price], 'must be greater than 0' + end + + def test_numericality_only_integer + product = Product.new(name: 'widget', price: 5, quantity: 1.5, status: 'draft') + refute product.valid? + assert_includes product.errors[:quantity], 'must be an integer' + end + + def test_numericality_passes_for_valid_number + product = Product.new(name: 'widget', price: 9.99, quantity: 3, status: 'draft') + assert product.valid?, product.errors.full_messages.join(', ') + end + + def test_inclusion_rejects_value_outside_set + product = Product.new(name: 'widget', price: 1, quantity: 1, status: 'deleted') + refute product.valid? + assert_includes product.errors[:status], 'is not included in the list' + end + + def test_inclusion_accepts_value_in_set + product = Product.new(name: 'widget', price: 1, quantity: 1, status: 'published') + assert product.valid?, product.errors.full_messages.join(', ') + end + + def test_exclusion_rejects_reserved_values + klass = Class.new(Prism::Orm::Model) do + table_name :products + validates_exclusion_of :name, in: %w[admin root] + end + klass.store = @store + reserved = klass.new(name: 'admin') + refute reserved.valid? + assert_includes reserved.errors[:name], 'is reserved' + end + + def test_custom_validator_method_adds_error + product = Product.new(name: 'SHOUT', price: 1, quantity: 1, status: 'draft') + refute product.valid? + assert_includes product.errors[:name], 'must not be all caps' + end + + def test_custom_validator_block + klass = Class.new(Prism::Orm::Model) do + table_name :products + validate { errors.add(:base, 'always invalid') if name == 'reject-me' } + end + klass.store = @store + record = klass.new(name: 'reject-me') + refute record.valid? + assert_includes record.errors[:base], 'always invalid' + end + + def test_custom_message_on_numericality + klass = Class.new(Prism::Orm::Model) do + table_name :products + validates_numericality_of :price, greater_than: 0, message: 'needs a positive price' + end + klass.store = @store + record = klass.new(name: 'x', price: -1) + refute record.valid? + assert_includes record.errors[:price], 'needs a positive price' + end +end