diff --git a/test/test_orm_encryption.rb b/test/test_orm_encryption.rb new file mode 100644 index 0000000..1cf3c22 --- /dev/null +++ b/test/test_orm_encryption.rb @@ -0,0 +1,149 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmEncryptionTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-encryption-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:patients, %w[id name ssn notes]) + Prism::Orm::Encryption.primary_key = 'test-primary-key-do-not-use-in-prod' + + patient_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :ssn, deterministic: true + encrypts :notes + end + patient_class.store = @store + Object.const_set(:EncryptedPatient, patient_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Prism::Orm::Encryption.primary_key = nil + Object.send(:remove_const, :EncryptedPatient) if Object.const_defined?(:EncryptedPatient) + end + + def test_reading_back_the_attribute_returns_plaintext + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'allergic to penicillin') + assert_equal '123-45-6789', patient.ssn + assert_equal 'allergic to penicillin', patient.notes + end + + def test_the_stored_column_holds_ciphertext_not_plaintext + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'secret note') + raw_row = @store.find(:patients, patient.id) + refute_equal '123-45-6789', raw_row['ssn'] + refute_equal 'secret note', raw_row['notes'] + refute_includes raw_row['ssn'], '123-45-6789' + end + + def test_plaintext_survives_a_reload_from_disk + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + reloaded = EncryptedPatient.find(patient.id) + assert_equal '123-45-6789', reloaded.ssn + assert_equal 'note', reloaded.notes + end + + def test_non_deterministic_column_produces_different_ciphertext_each_time + a = EncryptedPatient.create!(name: 'A', ssn: '111-11-1111', notes: 'same note') + b = EncryptedPatient.create!(name: 'B', ssn: '222-22-2222', notes: 'same note') + raw_a = @store.find(:patients, a.id) + raw_b = @store.find(:patients, b.id) + refute_equal raw_a['notes'], raw_b['notes'] + end + + def test_deterministic_column_produces_the_same_ciphertext_for_the_same_plaintext + a = EncryptedPatient.create!(name: 'A', ssn: '999-99-9999', notes: 'x') + b = EncryptedPatient.create!(name: 'B', ssn: '999-99-9999', notes: 'y') + raw_a = @store.find(:patients, a.id) + raw_b = @store.find(:patients, b.id) + assert_equal raw_a['ssn'], raw_b['ssn'] + end + + def test_where_on_a_deterministic_column_matches_by_plaintext + match = EncryptedPatient.create!(name: 'Match', ssn: '555-55-5555', notes: 'n') + EncryptedPatient.create!(name: 'Other', ssn: '666-66-6666', notes: 'n') + found = EncryptedPatient.where(ssn: '555-55-5555').to_a + assert_equal [match.id], found.map(&:id) + end + + def test_find_by_on_a_deterministic_column_matches_by_plaintext + match = EncryptedPatient.create!(name: 'Match', ssn: '777-77-7777', notes: 'n') + found = EncryptedPatient.find_by(ssn: '777-77-7777') + assert_equal match.id, found.id + end + + def test_querying_a_non_deterministic_column_by_plaintext_raises + error = assert_raises(Prism::Orm::Encryption::Error) { EncryptedPatient.where(notes: 'anything').to_a } + assert_match(/not deterministic/, error.message) + end + + def test_nil_round_trips_as_nil + patient = EncryptedPatient.create!(name: 'Nil', ssn: nil, notes: nil) + assert_nil patient.ssn + assert_nil patient.notes + raw_row = @store.find(:patients, patient.id) + assert_nil raw_row['ssn'] + reloaded = EncryptedPatient.find(patient.id) + assert_nil reloaded.ssn + end + + def test_a_tampered_ciphertext_raises_on_read_instead_of_returning_garbage + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + row = @store.find(:patients, patient.id) + tampered = row.dup + tampered['notes'] = row['notes'].sub(/^./) { |c| c == 'A' ? 'B' : 'A' } + @store.update(:patients, patient.id, tampered) + reloaded = EncryptedPatient.find(patient.id) + assert_raises(Prism::Orm::Encryption::Error) { reloaded.notes } + end + + def test_encrypted_attributes_are_hidden_from_json_by_default + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + hash = patient.serializable_hash + refute_includes hash.keys, 'ssn' + refute_includes hash.keys, 'notes' + assert_equal 'Alice', hash['name'] + end + + def test_an_explicit_only_can_still_opt_a_hidden_column_back_into_json + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: 'note') + hash = patient.serializable_hash(only: [:ssn]) + assert_equal patient['ssn'], hash['ssn'] + end + + def test_two_different_attributes_on_the_same_record_get_independent_keys + patient = EncryptedPatient.create!(name: 'Alice', ssn: '123-45-6789', notes: '123-45-6789') + row = @store.find(:patients, patient.id) + refute_equal row['ssn'], row['notes'] + end + + def test_a_column_scoped_key_overrides_the_shared_primary_key + scoped_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes, key: 'a-completely-different-secret' + end + scoped_class.store = @store + record = scoped_class.create!(name: 'Bob', notes: 'private') + assert_equal 'private', record.notes + + default_key_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes + end + default_key_class.store = @store + assert_raises(Prism::Orm::Encryption::Error) { default_key_class.find(record.id).notes } + end + + def test_encrypts_without_any_key_configured_raises_a_clear_error + Prism::Orm::Encryption.primary_key = nil + unconfigured_class = Class.new(Prism::Orm::Model) do + table_name :patients + encrypts :notes + end + unconfigured_class.store = @store + error = assert_raises(Prism::Orm::Encryption::Error) { unconfigured_class.new(notes: 'x') } + assert_match(/primary_key=/, error.message) + end +end