Files
sure/test/lib/tasks/security_backfill_test.rb
Stephen Jolly 27aa222ed3 Stop backfill_encryption double-encoding json/jsonb columns (#2615)
* fix(backfill): stop backfill_encryption double-encoding json/jsonb columns

security:backfill_encryption's plaintext fallback reads jsonb columns via
read_attribute_before_type_cast, which returns the JSON text; assigning
that String to the encrypted setter encrypts the text itself, so the
column thereafter decrypts to a String instead of the original
Array/Hash. Parse the raw value for json/jsonb columns before handing it
to the encryptor. Adds a regression test that fails on main.

Fixes #2611

* fix(backfill): gate backfill on nil-ness so empty values get encrypted

Addresses the Codex review comment on #2615: empty values ({}, [], \"\")
are plaintext that needs encrypting, but the present? gates skipped them,
leaving data the encrypted getters raise on once keys are live. Several
payload columns default to {}. Also applies the same nil-gate to
backfill_sessions user_agent (same idiom; precautionary). Regression
test added.

* docs(security): scope note - backfill doesn't fix any existing double-encoding

Per review: rows corrupted by the pre-fix task decrypt successfully to a
String, so this task cannot distinguish them from legitimately stored
strings; auto-repair would risk mangling valid data for a bounded, shrinking
cohort. Document the limitation and point affected operators at the manual
recovery script in #2611. Also adopt column.type over sql_type substring
matching (review nitpick); behaviour unchanged.
2026-07-08 08:05:49 +02:00

74 lines
3.6 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class SecurityBackfillTest < ActiveSupport::TestCase
# Follows the suite convention (see test/encryption_verification_test.rb):
# runs only when explicit encryption keys are configured, e.g.
#
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=test \
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=test \
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=test \
# bin/rails test test/lib/tasks/security_backfill_test.rb
setup do
skip "Encryption not configured" unless LunchflowAccount.encryption_ready?
Rails.application.load_tasks unless Rake::Task.task_defined?("security:backfill_encryption")
Rake::Task["security:backfill_encryption"].reenable
end
# Lunchflow is a representative provider model, chosen arbitrarily: the bug
# this guards against applies identically to every json/jsonb encrypts
# column the task touches (the Plaid/SimpleFin/Enable Banking/... raw
# payload columns), via the shared safe_read_field helper.
test "backfill preserves jsonb payload structure and string fields" do
item = LunchflowItem.new(family: families(:dylan_family), name: "Backfill Test", api_key: "seed")
item.save!(validate: false)
account = item.lunchflow_accounts.create!(
name: "Backfill Test Account", currency: "GBP", account_id: "backfill-test-1")
payload = [ { "id" => "tx-1", "amount" => -4.5, "currency" => "GBP", "date" => "2026-07-01" } ]
# Simulate rows written before encryption was enabled: bypass the encrypted
# setters and write plaintext values directly to the columns.
ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql([
"UPDATE lunchflow_accounts SET raw_transactions_payload = ?::jsonb WHERE id = ?",
payload.to_json, account.id ]))
ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql([
"UPDATE lunchflow_items SET api_key = ? WHERE id = ?", "plaintext-key", item.id ]))
capture_io { Rake::Task["security:backfill_encryption"].invoke("500", "false") }
account.reload
assert_kind_of Array, account.raw_transactions_payload,
"jsonb payload must decrypt to its original structure, not the JSON text"
assert_equal "tx-1", account.raw_transactions_payload.first["id"]
assert_equal "plaintext-key", item.reload.api_key
# The stored value is ciphertext, not plaintext jsonb
at_rest = account.read_attribute_before_type_cast(:raw_transactions_payload).to_s
refute_includes at_rest, "tx-1"
end
# Several payload columns default to {} — Rails presence checks treat empty
# Hash/Array as absent, so the backfill must gate on nil-ness or empty
# payloads stay plaintext and raise on every read once keys are live.
test "backfill encrypts empty json payloads" do
item = LunchflowItem.new(family: families(:dylan_family), name: "Backfill Empty Test", api_key: "seed")
item.save!(validate: false)
account = item.lunchflow_accounts.create!(
name: "Backfill Empty Test Account", currency: "GBP", account_id: "backfill-test-2")
# Plaintext empty payload, as left by a pre-encryption install
ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql([
"UPDATE lunchflow_accounts SET raw_payload = ?::jsonb WHERE id = ?", "{}", account.id ]))
capture_io { Rake::Task["security:backfill_encryption"].invoke("500", "false") }
account.reload
assert_equal({}, account.raw_payload,
"an empty payload must decrypt cleanly after the backfill, not remain plaintext")
assert_match(/"p":/, account.read_attribute_before_type_cast(:raw_payload).to_s,
"the stored value must be an encryption envelope, not plaintext {}")
end
end