diff --git a/.env.development b/.env.development new file mode 100644 index 0000000000..02b4075f33 --- /dev/null +++ b/.env.development @@ -0,0 +1,6 @@ +# Active Record encryption keys for local development. +# +# These are throwaway, non-production keys, committed on purpose so that a fresh clone boots +# without any setup. Production and staging read the same variables from the real environment. +AR_ENCRYPTION_PRIMARY_KEY=WSUDLoAAbKSnldNQe4YXVoC5WTdBiiFt +AR_ENCRYPTION_KEY_DERIVATION_SALT=6dlX6mQqooFztaeIOxOBHJ9SmynoWaXC diff --git a/.env.example b/.env.example index 5df0115d4d..6300c0f5ea 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,10 @@ RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe # [OPTIONAL] - Used to fetch copies of production DB AZURE_STORAGE_ACCOUNT_NAME= AZURE_STORAGE_ACCESS_KEY= + +# [PRODUCTION/STAGING ONLY] - Active Record encryption keys, used to encrypt PII at rest. +# Locally these come from the committed .env.development and .env.test, so you can leave them blank. +# Generate a pair with: bin/rails db:encryption:init +# Losing these keys means the encrypted data cannot be recovered. +AR_ENCRYPTION_PRIMARY_KEY= +AR_ENCRYPTION_KEY_DERIVATION_SALT= diff --git a/.env.test b/.env.test new file mode 100644 index 0000000000..70150f925b --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +# Active Record encryption keys for the test suite. +# +# These are throwaway, non-production keys, committed on purpose: CI has no .env file, and dotenv +# does not load .env.development in the test environment. +AR_ENCRYPTION_PRIMARY_KEY=3WdNnb4TBItgQxzJd5CUdk2g2v03UvzZ +AR_ENCRYPTION_KEY_DERIVATION_SALT=SggkroBqG4UVZINKNAaURapc6PREICJ5 diff --git a/app/controllers/partners/children_controller.rb b/app/controllers/partners/children_controller.rb index 1c9ab499dc..a63869075b 100644 --- a/app/controllers/partners/children_controller.rb +++ b/app/controllers/partners/children_controller.rb @@ -13,6 +13,13 @@ def index @children = @filterrific.find + # date_of_birth is encrypted => can't ORDER BY it in SQL. Sort the decrypted value in Ruby + # (safe: this index isn't paginated, whole set is already loaded). + if params[:sort] == "date_of_birth" + @children = @children.sort_by { |c| [c.date_of_birth || Date.new(9999, 12, 31), c.last_name.to_s, c.id] } + @children = @children.reverse if sort_direction == "desc" + end + respond_to do |format| format.js format.html @@ -91,6 +98,7 @@ def child_params end def sort_order + return "last_name, id" if params[:sort] == "date_of_birth" # encrypted; sorted in Ruby, not SQL sort_column + ' ' + sort_direction end diff --git a/app/models/partners/authorized_family_member.rb b/app/models/partners/authorized_family_member.rb index efa60b2626..f9045195c4 100644 --- a/app/models/partners/authorized_family_member.rb +++ b/app/models/partners/authorized_family_member.rb @@ -4,7 +4,7 @@ # # id :bigint not null, primary key # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # last_name :string @@ -18,6 +18,9 @@ class AuthorizedFamilyMember < Base belongs_to :family has_many :child_item_requests, dependent: :nullify + attribute :date_of_birth, :date + encrypts :date_of_birth + def display_name "#{first_name} #{last_name}" end diff --git a/app/models/partners/child.rb b/app/models/partners/child.rb index d34def9f8b..5879e858fc 100644 --- a/app/models/partners/child.rb +++ b/app/models/partners/child.rb @@ -7,7 +7,7 @@ # archived :boolean # child_lives_with :jsonb # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # health_insurance :jsonb @@ -27,6 +27,9 @@ class Child < Base has_many :child_item_requests, dependent: :destroy has_and_belongs_to_many :requested_items, class_name: 'Item' + attribute :date_of_birth, :date + encrypts :date_of_birth + include Filterable include Exportable diff --git a/app/models/partners/family.rb b/app/models/partners/family.rb index 11d73da765..b208902224 100644 --- a/app/models/partners/family.rb +++ b/app/models/partners/family.rb @@ -28,6 +28,7 @@ module Partners class Family < Base has_paper_trail + encrypts :guardian_phone belongs_to :partner, class_name: '::Partner' has_many :children, dependent: :destroy has_many :authorized_family_members, dependent: :destroy diff --git a/app/models/partners/profile.rb b/app/models/partners/profile.rb index 2d58b93f10..b8c7d2733e 100644 --- a/app/models/partners/profile.rb +++ b/app/models/partners/profile.rb @@ -80,6 +80,13 @@ module Partners class Profile < Base has_paper_trail self.table_name = "partner_profiles" + + # Contact details of named individuals at the agency, so PII by the same standard as the + # product drive participants. None of these are queried by value, so non-deterministic. + encrypts :executive_director_email, :executive_director_phone, + :primary_contact_email, :primary_contact_phone, :primary_contact_mobile, + :pick_up_email, :pick_up_phone + belongs_to :partner has_one :organization, through: :partner, class_name: "::Organization" diff --git a/app/models/product_drive_participant.rb b/app/models/product_drive_participant.rb index dc1ab6823e..92f515233a 100644 --- a/app/models/product_drive_participant.rb +++ b/app/models/product_drive_participant.rb @@ -18,6 +18,7 @@ class ProductDriveParticipant < ApplicationRecord has_paper_trail + encrypts :phone, :email include Provideable include Geocodable diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb new file mode 100644 index 0000000000..962720eeb4 --- /dev/null +++ b/config/initializers/active_record_encryption.rb @@ -0,0 +1,13 @@ +# Keys come from the environment everywhere, so there is no env-specific branch here. Development +# and test read them from the committed .env.development and .env.test, which dotenv loads before +# initializers run. Production and staging get them from the real environment, and ENV.fetch has no +# default, so a missing key fails the boot loudly instead of silently writing data that cannot be +# read back later. +ActiveRecord::Encryption.configure( + primary_key: ENV.fetch("AR_ENCRYPTION_PRIMARY_KEY"), + key_derivation_salt: ENV.fetch("AR_ENCRYPTION_KEY_DERIVATION_SALT"), + + # Bridge for migrating existing plaintext rows: lets reads of not-yet-backfilled + # columns work instead of raising. Flip to false (follow-up) after prod backfill. + support_unencrypted_data: true +) diff --git a/db/migrate/20260703191550_encrypt_dates_of_birth.rb b/db/migrate/20260703191550_encrypt_dates_of_birth.rb new file mode 100644 index 0000000000..57cf407d4f --- /dev/null +++ b/db/migrate/20260703191550_encrypt_dates_of_birth.rb @@ -0,0 +1,21 @@ +class EncryptDatesOfBirth < ActiveRecord::Migration[8.0] + def up + # date -> text: encrypted values are string blobs and won't fit a date column. + # + # `using` pins the serialization format. Without it Postgres falls back to the date output + # function, which follows the server's DateStyle setting: under `SQL, MDY` the same date is + # written as "03/04/1990", which Ruby then reads back as April 3rd. This rewrite is in place + # and irreversible, so the format cannot be left to a server setting. + safety_assured do + change_column :children, :date_of_birth, :text, using: "to_char(date_of_birth, 'YYYY-MM-DD')" + change_column :authorized_family_members, :date_of_birth, :text, using: "to_char(date_of_birth, 'YYYY-MM-DD')" + end + end + + def down + safety_assured do + change_column :children, :date_of_birth, :date, using: "date_of_birth::date" + change_column :authorized_family_members, :date_of_birth, :date, using: "date_of_birth::date" + end + end +end diff --git a/db/migrate/20260703191552_backfill_encrypted_pii.rb b/db/migrate/20260703191552_backfill_encrypted_pii.rb new file mode 100644 index 0000000000..bfabe28241 --- /dev/null +++ b/db/migrate/20260703191552_backfill_encrypted_pii.rb @@ -0,0 +1,46 @@ +class BackfillEncryptedPii < ActiveRecord::Migration[8.0] + # `encrypts` only encrypts new writes, so existing rows stay plaintext until re-saved. + # This re-encrypts them in place. `record.encrypt` uses update_columns (no validations, + # callbacks, timestamps or paper_trail), and relies on + # config.active_record.encryption.support_unencrypted_data = true to read the plaintext. + # + # No wrapping transaction: each row commits on its own, so a big table doesn't hold one long + # transaction/lock, and a run that fails halfway keeps the rows it already encrypted. + disable_ddl_transaction! + + MODELS = [ + Partners::Child, + Partners::AuthorizedFamilyMember, + Partners::Family, + Partners::Profile, + ProductDriveParticipant + ].freeze + + def up + MODELS.each do |model| + model.find_in_batches do |batch| + batch.reject { |record| encrypted?(record) }.each(&:encrypt) + sleep(0.1) # throttle prod DB load (~40-50k rows); matches BackfillItemReportingCategoryField + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + # `encrypt` writes unconditionally, and the encryption is non-deterministic, so re-encrypting an + # already-encrypted row rewrites it with fresh ciphertext: same value, wasted write. Skipping + # those rows is what lets a run that failed halfway be re-run and only do what is left. + # + # Ciphertext is opaque to Postgres, so there is no scope for this: the check is per record. + # A blank value counts as done, since there is nothing to encrypt. + def encrypted?(record) + record.class.encrypted_attributes.all? do |attribute| + value = record.read_attribute_before_type_cast(attribute) + value.blank? || record.encrypted_attribute?(attribute) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6763e951c6..7579e2a730 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_13_201123) do +ActiveRecord::Schema[8.0].define(version: 2026_07_03_191552) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -112,7 +112,7 @@ create_table "authorized_family_members", force: :cascade do |t| t.string "first_name" t.string "last_name" - t.date "date_of_birth" + t.text "date_of_birth" t.string "gender" t.text "comments" t.bigint "family_id" @@ -173,7 +173,7 @@ create_table "children", force: :cascade do |t| t.string "first_name" t.string "last_name" - t.date "date_of_birth" + t.text "date_of_birth" t.string "gender" t.jsonb "child_lives_with" t.jsonb "race" diff --git a/spec/factories/partners/children.rb b/spec/factories/partners/children.rb index 76329c7226..9859b521a4 100644 --- a/spec/factories/partners/children.rb +++ b/spec/factories/partners/children.rb @@ -7,7 +7,7 @@ # archived :boolean # child_lives_with :jsonb # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # health_insurance :jsonb diff --git a/spec/migrations/backfill_encrypted_pii_spec.rb b/spec/migrations/backfill_encrypted_pii_spec.rb new file mode 100644 index 0000000000..9ca59d99ed --- /dev/null +++ b/spec/migrations/backfill_encrypted_pii_spec.rb @@ -0,0 +1,73 @@ +require "rails_helper" +require Rails.root.join("db/migrate/20260703191552_backfill_encrypted_pii") + +RSpec.describe BackfillEncryptedPii, type: :migration do + let(:connection) { ActiveRecord::Base.connection } + let(:migration) { described_class.new } + + let(:family) { create(:partners_family) } + let(:child) { create(:partners_child, family: family) } + let(:participant) { create(:product_drive_participant) } + let(:profile) { create(:partner_profile) } + + # Rows as production holds them: written before `encrypts` existed, so still plaintext. + # They have to be seeded with raw SQL, since the models would encrypt them on the way in. + def write_plaintext(record, values) + assignments = values.map { |column, value| "#{column} = #{connection.quote(value)}" }.join(", ") + connection.execute("UPDATE #{record.class.table_name} SET #{assignments} WHERE id = #{record.id}") + record.reload + end + + def stored(record, column) + connection.select_value("SELECT #{column} FROM #{record.class.table_name} WHERE id = #{record.id}") + end + + it "encrypts the rows that are still plaintext, keeping their values" do + write_plaintext(child, date_of_birth: "1990-03-04") + write_plaintext(family, guardian_phone: "555-1234") + write_plaintext(participant, phone: "555-9876", email: "donor@example.com") + write_plaintext(profile, primary_contact_email: "contact@example.com", pick_up_phone: "555-4444") + + migration.up + + expect(stored(child, :date_of_birth)).to start_with('{"p":') + expect(stored(family, :guardian_phone)).to start_with('{"p":') + expect(stored(participant, :email)).to start_with('{"p":') + expect(stored(profile, :primary_contact_email)).to start_with('{"p":') + + expect(child.reload.date_of_birth).to eq(Date.new(1990, 3, 4)) + expect(family.reload.guardian_phone).to eq("555-1234") + expect(participant.reload.phone).to eq("555-9876") + expect(participant.reload.email).to eq("donor@example.com") + expect(profile.reload.primary_contact_email).to eq("contact@example.com") + expect(profile.pick_up_phone).to eq("555-4444") + end + + it "leaves rows that are already encrypted untouched" do + # The factories write through the models, so these rows are encrypted already. Encryption is + # non-deterministic: if the migration re-encrypted them, the ciphertext would come out different. + ciphertext = stored(child, :date_of_birth) + + expect { migration.up }.not_to change { stored(child, :date_of_birth) }.from(ciphertext) + end + + it "picks up where a failed run stopped" do + encrypted_child = child + plaintext_child = create(:partners_child, family: family) + write_plaintext(plaintext_child, date_of_birth: "1985-12-31") + ciphertext = stored(encrypted_child, :date_of_birth) + + migration.up + + expect(stored(encrypted_child, :date_of_birth)).to eq(ciphertext) + expect(plaintext_child.reload.date_of_birth).to eq(Date.new(1985, 12, 31)) + end + + it "leaves a missing value null" do + write_plaintext(child, date_of_birth: nil) + + migration.up + + expect(stored(child, :date_of_birth)).to be_nil + end +end diff --git a/spec/migrations/encrypt_dates_of_birth_spec.rb b/spec/migrations/encrypt_dates_of_birth_spec.rb new file mode 100644 index 0000000000..4b46a8aaa4 --- /dev/null +++ b/spec/migrations/encrypt_dates_of_birth_spec.rb @@ -0,0 +1,103 @@ +require "rails_helper" +require Rails.root.join("db/migrate/20260703191550_encrypt_dates_of_birth") +require Rails.root.join("db/migrate/20260703191552_backfill_encrypted_pii") + +# `date_of_birth` is rewritten from `date` to `text` in place, so a lossy conversion would destroy +# the original value with nothing left to recover it from. These specs rewind the schema to the +# pre-migration state, seed the plaintext dates production still holds, then run the real migrations +# against real Postgres and check every date survives the round trip. +# +# Transactional fixtures roll the DDL back afterwards (Postgres DDL is transactional). +RSpec.describe EncryptDatesOfBirth, type: :migration do + let(:connection) { ActiveRecord::Base.connection } + let(:models) { [Partners::Child, Partners::AuthorizedFamilyMember] } + + let(:dates) do + [ + Date.new(2020, 2, 29), # leap day + Date.new(1990, 3, 4), # day and month are both valid months: silently swappable + Date.new(1985, 12, 31), # day cannot be a month: parses as nil rather than swapping + Date.new(1900, 1, 1) # distant past + ] + end + + let(:family) { create(:partners_family) } + # Seeded with no date of birth, so nothing is encrypted before the schema is rewound. + let(:children) { dates.map { create(:partners_child, family: family, date_of_birth: nil) } } + let(:members) do + dates.map { family.authorized_family_members.create!(first_name: "A", last_name: "B") } + end + + before do + reset_columns! + children + members + + # Rewind to the pre-migration schema and fill in the legacy plaintext dates. They have to be + # written with raw SQL: through the models, `encrypts` would encrypt them on the way in. + models.each do |model| + connection.change_column model.table_name, :date_of_birth, :date, using: "date_of_birth::date" + end + reset_columns! + children.zip(dates) { |child, date| write_legacy_date("children", child, date) } + members.zip(dates) { |member, date| write_legacy_date("authorized_family_members", member, date) } + end + + after { reset_columns! } + + def reset_columns! + models.each(&:reset_column_information) + end + + def write_legacy_date(table, record, date) + connection.execute(<<~SQL) + UPDATE #{table} SET date_of_birth = DATE '#{date.iso8601}' WHERE id = #{record.id} + SQL + end + + def run_migrations! + described_class.new.up + BackfillEncryptedPii.new.up + reset_columns! + end + + it "preserves every date of birth through the conversion and the backfill" do + run_migrations! + + expect(children.map { |child| child.reload.date_of_birth }).to eq(dates) + expect(members.map { |member| member.reload.date_of_birth }).to eq(dates) + end + + it "still reads the dates back as dates, not as strings" do + run_migrations! + + expect(children.first.reload.date_of_birth).to be_a(Date) + end + + it "leaves a missing date of birth null rather than inventing one" do + childless_date = create(:partners_child, family: family, date_of_birth: nil) + + run_migrations! + + expect(childless_date.reload.date_of_birth).to be_nil + end + + it "stores the dates as ciphertext" do + run_migrations! + + stored = connection.select_values("SELECT date_of_birth FROM children WHERE date_of_birth IS NOT NULL") + expect(stored).to all(start_with('{"p":')) + expect(stored.join).not_to include("1990-03-04") + end + + it "converts the dates identically under a non-ISO DateStyle" do + # This is what `using: to_char(...)` buys. A bare `change_column` serializes the date with the + # server's DateStyle, so under `SQL, MDY` 1990-03-04 is written as "03/04/1990" and Ruby, which + # reads ambiguous dates day-first, hands the backfill April 3rd to encrypt. + connection.execute("SET DateStyle TO 'SQL, MDY'") + + run_migrations! + + expect(children.map { |child| child.reload.date_of_birth }).to eq(dates) + end +end diff --git a/spec/models/partners/authorized_family_member_spec.rb b/spec/models/partners/authorized_family_member_spec.rb index 8da9bbcb49..7740783a64 100644 --- a/spec/models/partners/authorized_family_member_spec.rb +++ b/spec/models/partners/authorized_family_member_spec.rb @@ -4,7 +4,7 @@ # # id :bigint not null, primary key # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # last_name :string @@ -19,6 +19,19 @@ it { should have_many(:child_item_requests).dependent(:nullify) } end + describe "encrypts date_of_birth at rest" do + let(:family) { create(:partners_family) } + let(:member) do + family.authorized_family_members.create!(first_name: "A", last_name: "B", date_of_birth: Date.new(2020, 1, 1)) + end + + it "stores ciphertext but round-trips as a Date" do + expect(member.reload.date_of_birth).to eq(Date.new(2020, 1, 1)) + expect(member.date_of_birth).to be_a(Date) + expect(member.ciphertext_for(:date_of_birth)).not_to include("2020-01-01") + end + end + describe "#display_name" do let(:partners_family) { create(:partners_family) } let(:authorized_family_member) { partners_family.create_authorized } diff --git a/spec/models/partners/child_spec.rb b/spec/models/partners/child_spec.rb index 7934f19a8e..92c85a8b83 100644 --- a/spec/models/partners/child_spec.rb +++ b/spec/models/partners/child_spec.rb @@ -7,7 +7,7 @@ # archived :boolean # child_lives_with :jsonb # comments :text -# date_of_birth :date +# date_of_birth :text # first_name :string # gender :string # health_insurance :jsonb @@ -27,6 +27,16 @@ it { should have_and_belong_to_many(:requested_items).class_name('Item') } end + describe "encrypts date_of_birth at rest" do + let(:child) { create(:partners_child, date_of_birth: Date.new(2020, 1, 1)) } + + it "stores ciphertext but round-trips as a Date" do + expect(child.reload.date_of_birth).to eq(Date.new(2020, 1, 1)) + expect(child.date_of_birth).to be_a(Date) + expect(child.ciphertext_for(:date_of_birth)).not_to include("2020-01-01") + end + end + describe "#display_name" do subject { partners_child } let(:partners_child) { create(:partners_child) } diff --git a/spec/models/partners/family_spec.rb b/spec/models/partners/family_spec.rb index f039189178..cceac0e628 100644 --- a/spec/models/partners/family_spec.rb +++ b/spec/models/partners/family_spec.rb @@ -33,6 +33,15 @@ it { should have_many(:authorized_family_members).dependent(:destroy) } end + describe "encrypts guardian_phone at rest" do + let(:family) { create(:partners_family, guardian_phone: "555-123-4567") } + + it "stores ciphertext but round-trips the value" do + expect(family.reload.guardian_phone).to eq("555-123-4567") + expect(family.ciphertext_for(:guardian_phone)).not_to include("555-123-4567") + end + end + describe "validations" do subject { partners_family } let(:partners_family) { FactoryBot.build(:partners_family) } diff --git a/spec/models/partners/profile_spec.rb b/spec/models/partners/profile_spec.rb index 5d3a4ae7ae..03bbc25a4f 100644 --- a/spec/models/partners/profile_spec.rb +++ b/spec/models/partners/profile_spec.rb @@ -307,4 +307,27 @@ describe "versioning" do it { is_expected.to be_versioned } end + + describe "encrypts the contact details at rest" do + let(:profile) do + create(:partner_profile, + executive_director_email: "director@example.com", + executive_director_phone: "555-111-1111", + primary_contact_email: "contact@example.com", + primary_contact_phone: "555-222-2222", + primary_contact_mobile: "555-333-3333", + pick_up_email: "pickup@example.com", + pick_up_phone: "555-444-4444") + end + + it "stores them as ciphertext but round-trips them" do + expect(profile.reload.executive_director_email).to eq("director@example.com") + expect(profile.primary_contact_phone).to eq("555-222-2222") + expect(profile.pick_up_email).to eq("pickup@example.com") + + expect(profile.ciphertext_for(:executive_director_email)).not_to include("director@example.com") + expect(profile.ciphertext_for(:primary_contact_phone)).not_to include("555-222-2222") + expect(profile.ciphertext_for(:pick_up_email)).not_to include("pickup@example.com") + end + end end diff --git a/spec/models/product_drive_participant_spec.rb b/spec/models/product_drive_participant_spec.rb index 94abe444bd..45ab6610f2 100644 --- a/spec/models/product_drive_participant_spec.rb +++ b/spec/models/product_drive_participant_spec.rb @@ -19,6 +19,17 @@ RSpec.describe ProductDriveParticipant, type: :model do it_behaves_like "provideable" + describe "encrypts phone and email at rest" do + let(:participant) { create(:product_drive_participant, phone: "555-123-4567", email: "person@example.com") } + + it "stores phone and email as ciphertext but round-trips them" do + expect(participant.reload.phone).to eq("555-123-4567") + expect(participant.email).to eq("person@example.com") + expect(participant.ciphertext_for(:phone)).not_to include("555-123-4567") + expect(participant.ciphertext_for(:email)).not_to include("person@example.com") + end + end + context "Validations" do it "is invalid unless it has either a phone number or an email" do expect(build(:product_drive_participant, :no_contact_name_or_email, contact_name: "George Henry")).not_to be_valid diff --git a/spec/requests/partners/children_requests_spec.rb b/spec/requests/partners/children_requests_spec.rb index 8547c91a30..227a90f1fb 100644 --- a/spec/requests/partners/children_requests_spec.rb +++ b/spec/requests/partners/children_requests_spec.rb @@ -57,5 +57,26 @@ CSV expect(response.body).to eq(csv) end + + # date_of_birth is encrypted (non-deterministic) => SQL ORDER BY sorts ciphertext. + # The controller must sort by the decrypted date in Ruby instead. + describe "sorting by the encrypted date_of_birth" do + let!(:child_no_dob) do + create(:partners_child, first_name: "Nodob", last_name: "Smith", date_of_birth: nil, family: family) + end + + it "sorts ascending by actual date, with nils last" do + get partners_children_path(sort: "date_of_birth", direction: "asc") + body = response.body + expect(body.index("Jane")).to be < body.index("John") # 2018 before 2019 + expect(body.index("John")).to be < body.index("Nodob") # nil date last + end + + it "sorts descending by actual date" do + get partners_children_path(sort: "date_of_birth", direction: "desc") + body = response.body + expect(body.index("John")).to be < body.index("Jane") # 2019 before 2018 + end + end end end