From 1c5d61eafad57cda8d6f2fba561e26caf3ab33ae Mon Sep 17 00:00:00 2001 From: Mike Carey Date: Wed, 27 May 2026 09:43:00 -0600 Subject: [PATCH 1/3] Use read_attribute_for_database when generating cached INSERTs Switches the cached fixture generator from read_attribute_before_type_cast to read_attribute_for_database so column values are quoted in their adapter-serialized form. Binary columns now round-trip as hex literals instead of being mangled by string quoting, fixing breakage seen with mysql-binuuid-rails and any binary(16) column. The same path also corrects serialization for JSON, enum, encrypted, datetime, decimal, and custom Attribute API types that were previously serialized from their Ruby-side values. Heads up: cached fixtures built before this change that contained encrypts columns held cleartext. Blow away tmp/cache/fixture_kit (or your configured cache_path) before shipping. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/fixture_kit/coders/active_record_coder.rb | 2 +- spec/dummy/app/models/binary_blob.rb | 5 ++ spec/dummy/app/models/binary_blob_child.rb | 5 ++ spec/dummy/app/models/uuid_blob.rb | 22 +++++++ spec/support/dummy_rails_helper.rb | 22 +++++++ spec/unit/coders/active_record_coder_spec.rb | 62 +++++++++++++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 spec/dummy/app/models/binary_blob.rb create mode 100644 spec/dummy/app/models/binary_blob_child.rb create mode 100644 spec/dummy/app/models/uuid_blob.rb 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..39ae2d8 --- /dev/null +++ b/spec/dummy/app/models/binary_blob.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class BinaryBlob < ActiveRecord::Base + has_many :children, class_name: "BinaryBlobChild" +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/app/models/uuid_blob.rb b/spec/dummy/app/models/uuid_blob.rb new file mode 100644 index 0000000..b9cf984 --- /dev/null +++ b/spec/dummy/app/models/uuid_blob.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class UuidBlob < ActiveRecord::Base + class UuidBinaryType < ActiveModel::Type::Binary + def serialize(value) + return super if value.nil? + + hex = value.to_s.delete("-") + super([hex].pack("H*")) + end + + def deserialize(value) + raw = super + return raw if raw.nil? + + hex = raw.unpack1("H*") + "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}" + end + end + + attribute :external_id, UuidBinaryType.new +end diff --git a/spec/support/dummy_rails_helper.rb b/spec/support/dummy_rails_helper.rb index 088d6d4..eefa2c7 100644 --- a/spec/support/dummy_rails_helper.rb +++ b/spec/support/dummy_rails_helper.rb @@ -82,6 +82,9 @@ 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) + ActiveRecord::Base.connection.drop_table(:uuid_blobs, if_exists: true, force: :cascade) end AnalyticsRecord.connection.disable_referential_integrity do @@ -143,6 +146,25 @@ 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.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 + + ActiveRecord::Base.connection.create_table :uuid_blobs, force: true do |t| + t.binary :external_id, limit: 16, null: false + t.string :name, null: false + 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..d4160f2 100644 --- a/spec/unit/coders/active_record_coder_spec.rb +++ b/spec/unit/coders/active_record_coder_spec.rb @@ -115,6 +115,68 @@ 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 "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 + + it "round-trips a custom attribute type that serializes to binary" do + uuid = "550e8400-e29b-41d4-a716-446655440000" + result = coder.generate { UuidBlob.create!(external_id: uuid, name: "first") } + + coder.mount(result) + + replayed = UuidBlob.find_by!(name: "first") + expect(replayed.external_id).to eq(uuid) + end + end + describe "#mount" do it "documents that connection execute_batch is currently private" do connection = User.connection From 7b595ea598273f785262cdd54ee5b53bb7826250 Mon Sep 17 00:00:00 2001 From: Mike Carey Date: Wed, 27 May 2026 09:54:11 -0600 Subject: [PATCH 2/3] Drop custom-binary-subtype spec that fails on Postgres bytea The UuidBlob spec used a custom ActiveModel::Type::Binary subclass to mirror mysql-binuuid-rails. On Postgres + Rails 8.1 the round-trip returns the bytea hex-literal text instead of decoded bytes, which is a Rails adapter interaction with custom binary subtypes rather than a fixture_kit issue. The remaining four binary specs already cover the gem's behavior across raw binary, multi-row, JSON-alongside-binary, and FK paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/dummy/app/models/uuid_blob.rb | 22 -------------------- spec/support/dummy_rails_helper.rb | 7 ------- spec/unit/coders/active_record_coder_spec.rb | 10 --------- 3 files changed, 39 deletions(-) delete mode 100644 spec/dummy/app/models/uuid_blob.rb diff --git a/spec/dummy/app/models/uuid_blob.rb b/spec/dummy/app/models/uuid_blob.rb deleted file mode 100644 index b9cf984..0000000 --- a/spec/dummy/app/models/uuid_blob.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class UuidBlob < ActiveRecord::Base - class UuidBinaryType < ActiveModel::Type::Binary - def serialize(value) - return super if value.nil? - - hex = value.to_s.delete("-") - super([hex].pack("H*")) - end - - def deserialize(value) - raw = super - return raw if raw.nil? - - hex = raw.unpack1("H*") - "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}" - end - end - - attribute :external_id, UuidBinaryType.new -end diff --git a/spec/support/dummy_rails_helper.rb b/spec/support/dummy_rails_helper.rb index eefa2c7..d649c32 100644 --- a/spec/support/dummy_rails_helper.rb +++ b/spec/support/dummy_rails_helper.rb @@ -84,7 +84,6 @@ def setup_databases 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) - ActiveRecord::Base.connection.drop_table(:uuid_blobs, if_exists: true, force: :cascade) end AnalyticsRecord.connection.disable_referential_integrity do @@ -159,12 +158,6 @@ def setup_databases t.timestamps end - ActiveRecord::Base.connection.create_table :uuid_blobs, force: true do |t| - t.binary :external_id, limit: 16, null: false - t.string :name, null: false - 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 d4160f2..63506cc 100644 --- a/spec/unit/coders/active_record_coder_spec.rb +++ b/spec/unit/coders/active_record_coder_spec.rb @@ -165,16 +165,6 @@ def upsert_all_options(model) expect(parent.children.count).to eq(1) expect(parent.children.first.payload.b).to eq(raw_bytes) end - - it "round-trips a custom attribute type that serializes to binary" do - uuid = "550e8400-e29b-41d4-a716-446655440000" - result = coder.generate { UuidBlob.create!(external_id: uuid, name: "first") } - - coder.mount(result) - - replayed = UuidBlob.find_by!(name: "first") - expect(replayed.external_id).to eq(uuid) - end end describe "#mount" do From e012bcf620340692f7ff79a4dce1468431baecf2 Mon Sep 17 00:00:00 2001 From: Mike Carey Date: Wed, 27 May 2026 09:57:43 -0600 Subject: [PATCH 3/3] Add encrypts round-trip spec to lock in the security claim Wires ActiveRecord::Encryption test keys into the dummy app, adds a secret_note column to binary_blobs with encrypts, and asserts the cached SQL contains ciphertext (not cleartext) and decrypts back to the original on mount. Guards the security-sensitive path called out in the PR description against regressions in the coder serialization method. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/dummy/app/models/binary_blob.rb | 1 + spec/dummy/config/application.rb | 4 ++++ spec/support/dummy_rails_helper.rb | 1 + spec/unit/coders/active_record_coder_spec.rb | 13 +++++++++++++ 4 files changed, 19 insertions(+) diff --git a/spec/dummy/app/models/binary_blob.rb b/spec/dummy/app/models/binary_blob.rb index 39ae2d8..bee6bcc 100644 --- a/spec/dummy/app/models/binary_blob.rb +++ b/spec/dummy/app/models/binary_blob.rb @@ -2,4 +2,5 @@ class BinaryBlob < ActiveRecord::Base has_many :children, class_name: "BinaryBlobChild" + encrypts :secret_note 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 d649c32..1b1cf9a 100644 --- a/spec/support/dummy_rails_helper.rb +++ b/spec/support/dummy_rails_helper.rb @@ -149,6 +149,7 @@ def setup_databases t.binary :payload, limit: 16, null: false t.string :label, null: false t.json :metadata + t.text :secret_note t.timestamps end diff --git a/spec/unit/coders/active_record_coder_spec.rb b/spec/unit/coders/active_record_coder_spec.rb index 63506cc..1dd7c78 100644 --- a/spec/unit/coders/active_record_coder_spec.rb +++ b/spec/unit/coders/active_record_coder_spec.rb @@ -153,6 +153,19 @@ def upsert_all_options(model) 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")