diff --git a/lib/fixture_kit/coders/active_record_coder.rb b/lib/fixture_kit/coders/active_record_coder.rb index f1ee5d0..31b9196 100644 --- a/lib/fixture_kit/coders/active_record_coder.rb +++ b/lib/fixture_kit/coders/active_record_coder.rb @@ -74,7 +74,7 @@ def generate_statements(models) rows = [] model.unscoped.order(:id).find_each do |record| row_values = column_names.map do |col| - value = record.read_attribute_before_type_cast(col) + value = record.read_attribute_for_database(col) model.connection.quote(value) end rows << "(#{row_values.join(", ")})" diff --git a/spec/dummy/app/models/binary_blob.rb b/spec/dummy/app/models/binary_blob.rb new file mode 100644 index 0000000..bee6bcc --- /dev/null +++ b/spec/dummy/app/models/binary_blob.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class BinaryBlob < ActiveRecord::Base + has_many :children, class_name: "BinaryBlobChild" + encrypts :secret_note +end diff --git a/spec/dummy/app/models/binary_blob_child.rb b/spec/dummy/app/models/binary_blob_child.rb new file mode 100644 index 0000000..fa36cc4 --- /dev/null +++ b/spec/dummy/app/models/binary_blob_child.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class BinaryBlobChild < ActiveRecord::Base + belongs_to :binary_blob +end diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index 3b5e747..e144ddf 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -20,5 +20,9 @@ class Application < Rails::Application # Minimal config for testing config.active_record.maintain_test_schema = false + + config.active_record.encryption.primary_key = "test_primary_key_32_bytes_padding" + config.active_record.encryption.deterministic_key = "test_deterministic_key_32_padding" + config.active_record.encryption.key_derivation_salt = "test_key_derivation_salt_padding" end end diff --git a/spec/support/dummy_rails_helper.rb b/spec/support/dummy_rails_helper.rb index 088d6d4..1b1cf9a 100644 --- a/spec/support/dummy_rails_helper.rb +++ b/spec/support/dummy_rails_helper.rb @@ -82,6 +82,8 @@ def setup_databases ActiveRecord::Base.connection.drop_table(:vehicles, if_exists: true, force: :cascade) ActiveRecord::Base.connection.drop_table(:gadgets, if_exists: true, force: :cascade) ActiveRecord::Base.connection.drop_table(:computed_widgets, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:binary_blob_children, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:binary_blobs, if_exists: true, force: :cascade) end AnalyticsRecord.connection.disable_referential_integrity do @@ -143,6 +145,20 @@ def setup_databases t.timestamps end + ActiveRecord::Base.connection.create_table :binary_blobs, force: true do |t| + t.binary :payload, limit: 16, null: false + t.string :label, null: false + t.json :metadata + t.text :secret_note + t.timestamps + end + + ActiveRecord::Base.connection.create_table :binary_blob_children, force: true do |t| + t.binary :payload, limit: 16, null: false + t.references :binary_blob, null: false, foreign_key: true + t.timestamps + end + # Analytics database schema AnalyticsRecord.connection.create_table :activity_logs, force: true do |t| t.integer :external_user_id, null: false diff --git a/spec/unit/coders/active_record_coder_spec.rb b/spec/unit/coders/active_record_coder_spec.rb index 8ffd821..1dd7c78 100644 --- a/spec/unit/coders/active_record_coder_spec.rb +++ b/spec/unit/coders/active_record_coder_spec.rb @@ -115,6 +115,71 @@ def upsert_all_options(model) end end + describe "binary column encoding" do + let(:raw_bytes) { (240..255).map(&:chr).join.force_encoding(Encoding::ASCII_8BIT) } + + it "preserves exact bytes when fixtures are mounted from cache" do + result = coder.generate { BinaryBlob.create!(payload: raw_bytes, label: "first") } + + coder.mount(result) + + replayed = BinaryBlob.find_by!(label: "first") + expect(replayed.payload.b).to eq(raw_bytes) + end + + it "round-trips multiple rows with different payloads" do + other_bytes = (128..143).map(&:chr).join.force_encoding(Encoding::ASCII_8BIT) + result = coder.generate do + BinaryBlob.create!(payload: raw_bytes, label: "first") + BinaryBlob.create!(payload: other_bytes, label: "second") + end + + coder.mount(result) + + expect(BinaryBlob.find_by!(label: "first").payload.b).to eq(raw_bytes) + expect(BinaryBlob.find_by!(label: "second").payload.b).to eq(other_bytes) + end + + it "round-trips a JSON column alongside binary data" do + metadata = {"version" => 2, "tags" => ["alpha", "beta"]} + result = coder.generate do + BinaryBlob.create!(payload: raw_bytes, label: "with-json", metadata: metadata) + end + + coder.mount(result) + + replayed = BinaryBlob.find_by!(label: "with-json") + expect(replayed.payload.b).to eq(raw_bytes) + expect(replayed.metadata).to eq(metadata) + end + + it "stores ciphertext, not cleartext, for encrypts attributes" do + cleartext = "shhh-this-is-private-#{SecureRandom.hex(4)}" + result = coder.generate { BinaryBlob.create!(payload: raw_bytes, label: "encrypted", secret_note: cleartext) } + + sql = result[BinaryBlob] + expect(sql).not_to include(cleartext) + + coder.mount(result) + + replayed = BinaryBlob.find_by!(label: "encrypted") + expect(replayed.secret_note).to eq(cleartext) + end + + it "replays a foreign key relationship without integrity errors" do + result = coder.generate do + parent = BinaryBlob.create!(payload: raw_bytes, label: "parent") + BinaryBlobChild.create!(binary_blob: parent, payload: raw_bytes) + end + + coder.mount(result) + + parent = BinaryBlob.find_by!(label: "parent") + expect(parent.children.count).to eq(1) + expect(parent.children.first.payload.b).to eq(raw_bytes) + end + end + describe "#mount" do it "documents that connection execute_batch is currently private" do connection = User.connection