test: cover OrmValidatesEncryptedAttributeTest, teardown
This commit is contained in:
1 parent
7388c82502
commit
a51e8536c4
2 files changed
+178
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user