diff --git a/app/models/api_key.rb b/app/models/api_key.rb index e966c7f67..2e190500e 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -1,8 +1,12 @@ class ApiKey < ApplicationRecord + include Encryptable + belongs_to :user - # Use Rails built-in encryption for secure storage - encrypts :display_key, deterministic: true + # Encrypt display_key if ActiveRecord encryption is configured + if encryption_ready? + encrypts :display_key, deterministic: true + end # Constants SOURCES = [ "web", "mobile" ].freeze diff --git a/app/models/concerns/encryptable.rb b/app/models/concerns/encryptable.rb new file mode 100644 index 000000000..0ec5ae923 --- /dev/null +++ b/app/models/concerns/encryptable.rb @@ -0,0 +1,16 @@ +module Encryptable + extend ActiveSupport::Concern + + class_methods do + # Helper to detect if ActiveRecord Encryption is configured for this app. + # This allows encryption to be optional - if not configured, sensitive fields + # are stored in plaintext (useful for development or legacy deployments). + def encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + end +end diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb index 6bc356bc9..ad1293a21 100644 --- a/app/models/enable_banking_account.rb +++ b/app/models/enable_banking_account.rb @@ -1,5 +1,11 @@ class EnableBankingAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :enable_banking_item diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index 501df1632..8e84cdb62 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -1,21 +1,14 @@ class EnableBankingItem < ApplicationRecord - include Syncable, Provided, Unlinking + include Syncable, Provided, Unlinking, Encryptable enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :client_certificate, deterministic: true encrypts :session_id, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/invitation.rb b/app/models/invitation.rb index caf3e543e..fbdc6554d 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -1,7 +1,15 @@ class Invitation < ApplicationRecord + include Encryptable + belongs_to :family belongs_to :inviter, class_name: "User" + # Encrypt sensitive fields if ActiveRecord encryption is configured + if encryption_ready? + encrypts :token, deterministic: true + encrypts :email, deterministic: true, downcase: true + end + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true, inclusion: { in: %w[admin member] } validates :token, presence: true, uniqueness: true diff --git a/app/models/invite_code.rb b/app/models/invite_code.rb index f2cbcd7e0..1fa3e2fcb 100644 --- a/app/models/invite_code.rb +++ b/app/models/invite_code.rb @@ -1,4 +1,11 @@ class InviteCode < ApplicationRecord + include Encryptable + + # Encrypt token if ActiveRecord encryption is configured + if encryption_ready? + encrypts :token, deterministic: true, downcase: true + end + before_validation :generate_token, on: :create class << self diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb index 35d74dab9..c7ce80f7e 100644 --- a/app/models/lunchflow_account.rb +++ b/app/models/lunchflow_account.rb @@ -1,5 +1,11 @@ class LunchflowAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :lunchflow_item diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index a2af0fa25..9f4f4cc7a 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -1,20 +1,13 @@ class LunchflowItem < ApplicationRecord - include Syncable, Provided, Unlinking + include Syncable, Provided, Unlinking, Encryptable enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :api_key, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb index 3e3f5f774..da94291c1 100644 --- a/app/models/mobile_device.rb +++ b/app/models/mobile_device.rb @@ -1,4 +1,11 @@ class MobileDevice < ApplicationRecord + include Encryptable + + # Encrypt device_id if ActiveRecord encryption is configured + if encryption_ready? + encrypts :device_id, deterministic: true + end + belongs_to :user belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4217a7926..165ed1551 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,4 +1,14 @@ class PlaidAccount < ApplicationRecord + include Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + encrypts :raw_investments_payload + encrypts :raw_liabilities_payload + end + belongs_to :plaid_item # Legacy association via foreign key (will be removed after migration) diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index c60c8421c..c7f325134 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,21 +1,14 @@ class PlaidItem < ApplicationRecord - include Syncable, Provided + include Syncable, Provided, Encryptable enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :access_token, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/session.rb b/app/models/session.rb index 66faa0f59..b84d056c2 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,14 +1,18 @@ class Session < ApplicationRecord + include Encryptable + + # Encrypt user_agent if ActiveRecord encryption is configured + if encryption_ready? + encrypts :user_agent + end + belongs_to :user belongs_to :active_impersonator_session, -> { where(status: :in_progress) }, class_name: "ImpersonationSession", optional: true - before_create do - self.user_agent = Current.user_agent - self.ip_address = Current.ip_address - end + before_create :capture_session_info def get_preferred_tab(tab_key) data.dig("tab_preferences", tab_key) @@ -19,4 +23,13 @@ class Session < ApplicationRecord data["tab_preferences"][tab_key] = tab_value save! end + + private + + def capture_session_info + self.user_agent = Current.user_agent + raw_ip = Current.ip_address + self.ip_address = raw_ip + self.ip_address_digest = Digest::SHA256.hexdigest(raw_ip.to_s) if raw_ip.present? + end end diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb index 2a6592317..8b89db432 100644 --- a/app/models/simplefin_account.rb +++ b/app/models/simplefin_account.rb @@ -1,4 +1,13 @@ class SimplefinAccount < ApplicationRecord + include Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + encrypts :raw_holdings_payload + end + belongs_to :simplefin_item # Legacy association via foreign key (will be removed after migration) diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 0e3a761b4..07039ad77 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -1,5 +1,5 @@ class SimplefinItem < ApplicationRecord - include Syncable, Provided + include Syncable, Provided, Encryptable include SimplefinItem::Unlinking enum :status, { good: "good", requires_update: "requires_update" }, default: :good @@ -7,18 +7,11 @@ class SimplefinItem < ApplicationRecord # Virtual attribute for the setup token form field attr_accessor :setup_token - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :access_url, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index 21e3dbf33..d41b4c0e8 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class SsoProvider < ApplicationRecord - # Encrypt sensitive credentials using Rails 7.2 built-in encryption - encrypts :client_secret, deterministic: false + include Encryptable + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + if encryption_ready? + encrypts :client_secret, deterministic: false + end # Default enabled to true for new providers attribute :enabled, :boolean, default: true diff --git a/app/models/user.rb b/app/models/user.rb index 98f7d983b..3f22a1eb4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,8 +1,27 @@ class User < ApplicationRecord + include Encryptable + # Allow nil password for SSO-only users (JIT provisioning). # Custom validation ensures password is present for non-SSO registration. has_secure_password validations: false + # Encrypt sensitive fields if ActiveRecord encryption is configured + if encryption_ready? + # MFA secrets + encrypts :otp_secret, deterministic: true + # Note: otp_backup_codes is a PostgreSQL array column which doesn't support + # AR encryption. To encrypt it, a migration would be needed to change the + # column type from array to text/jsonb. + + # PII - emails (deterministic for lookups, downcase for case-insensitive) + encrypts :email, deterministic: true, downcase: true + encrypts :unconfirmed_email, deterministic: true, downcase: true + + # PII - names (non-deterministic for maximum security) + encrypts :first_name + encrypts :last_name + end + belongs_to :family belongs_to :last_viewed_chat, class_name: "Chat", optional: true has_many :sessions, dependent: :destroy @@ -332,7 +351,7 @@ class User < ApplicationRecord if (index = otp_backup_codes.index(code)) remaining_codes = otp_backup_codes.dup remaining_codes.delete_at(index) - update_column(:otp_backup_codes, remaining_codes) + update!(otp_backup_codes: remaining_codes) true else false diff --git a/db/migrate/20251217141218_add_hash_columns_for_security.rb b/db/migrate/20251217141218_add_hash_columns_for_security.rb new file mode 100644 index 000000000..42a89344b --- /dev/null +++ b/db/migrate/20251217141218_add_hash_columns_for_security.rb @@ -0,0 +1,15 @@ +class AddHashColumnsForSecurity < ActiveRecord::Migration[7.2] + def change + # Invitations - for token hashing + add_column :invitations, :token_digest, :string + add_index :invitations, :token_digest, unique: true, where: "token_digest IS NOT NULL" + + # InviteCodes - for token hashing + add_column :invite_codes, :token_digest, :string + add_index :invite_codes, :token_digest, unique: true, where: "token_digest IS NOT NULL" + + # Sessions - for IP hashing + add_column :sessions, :ip_address_digest, :string + add_index :sessions, :ip_address_digest + end +end diff --git a/db/schema.rb b/db/schema.rb index 7da95888c..1c91c437d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -679,18 +679,22 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_23_000000) do t.datetime "expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "token_digest" t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" t.index ["inviter_id"], name: "index_invitations_on_inviter_id" t.index ["token"], name: "index_invitations_on_token", unique: true + t.index ["token_digest"], name: "index_invitations_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "token", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "token_digest" t.index ["token"], name: "index_invite_codes_on_token", unique: true + t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -1103,7 +1107,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_23_000000) do t.datetime "subscribed_at" t.jsonb "prev_transaction_page_params", default: {} t.jsonb "data", default: {} + t.string "ip_address_digest" t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" + t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end diff --git a/lib/tasks/security_backfill.rake b/lib/tasks/security_backfill.rake new file mode 100644 index 000000000..49dd08c6b --- /dev/null +++ b/lib/tasks/security_backfill.rake @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +namespace :security do + desc "Backfill encryption for sensitive fields (idempotent). Args: batch_size, dry_run" + task :backfill_encryption, [ :batch_size, :dry_run ] => :environment do |_, args| + raw_batch = args[:batch_size].presence || ENV["BATCH_SIZE"].presence || "100" + raw_dry = args[:dry_run].presence || ENV["DRY_RUN"].presence + + batch_size = raw_batch.to_i + batch_size = 100 if batch_size <= 0 + + dry_run = case raw_dry.to_s.strip.downcase + when "0", "false", "no", "n" then false + when "1", "true", "yes", "y" then true + else + true # Default to dry run for safety + end + + # Check encryption configuration (use User model which includes Encryptable) + unless User.encryption_ready? + puts({ + ok: false, + error: "encryption_not_configured", + message: "ActiveRecord encryption is not configured. Set credentials or environment variables." + }.to_json) + exit 1 + end + + results = {} + puts "Starting security backfill (dry_run: #{dry_run}, batch_size: #{batch_size})..." + + # User fields (MFA + PII) + # Note: otp_backup_codes excluded - it's a PostgreSQL array column incompatible with AR encryption + results[:users] = backfill_model(User, %i[otp_secret email unconfirmed_email first_name last_name], batch_size, dry_run) + + # Invitation tokens and email + results[:invitations] = backfill_model(Invitation, %i[token email], batch_size, dry_run) + + # InviteCode tokens + results[:invite_codes] = backfill_model(InviteCode, %i[token], batch_size, dry_run) + + # Session user_agent (encryption) and ip_address_digest (hashing) + results[:sessions] = backfill_sessions(batch_size, dry_run) + + # MobileDevice device_id + results[:mobile_devices] = backfill_model(MobileDevice, %i[device_id], batch_size, dry_run) + + # Provider items + results[:plaid_items] = backfill_model(PlaidItem, %i[access_token raw_payload raw_institution_payload], batch_size, dry_run) + results[:simplefin_items] = backfill_model(SimplefinItem, %i[access_url raw_payload raw_institution_payload], batch_size, dry_run) + results[:lunchflow_items] = backfill_model(LunchflowItem, %i[api_key raw_payload raw_institution_payload], batch_size, dry_run) + results[:enable_banking_items] = backfill_model(EnableBankingItem, %i[client_certificate session_id raw_payload raw_institution_payload], batch_size, dry_run) + + # Provider accounts + results[:plaid_accounts] = backfill_model(PlaidAccount, %i[raw_payload raw_transactions_payload raw_investments_payload raw_liabilities_payload], batch_size, dry_run) + results[:simplefin_accounts] = backfill_model(SimplefinAccount, %i[raw_payload raw_transactions_payload raw_holdings_payload], batch_size, dry_run) + results[:lunchflow_accounts] = backfill_model(LunchflowAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) + results[:enable_banking_accounts] = backfill_model(EnableBankingAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) + + puts({ + ok: true, + dry_run: dry_run, + batch_size: batch_size, + results: results + }.to_json) + end + + def backfill_model(model_class, fields, batch_size, dry_run, &filter_block) + processed = 0 + updated = 0 + failed = [] + + model_class.order(:id).in_batches(of: batch_size) do |batch| + batch.each do |record| + processed += 1 + + # Skip if filter block returns false + next if block_given? && !filter_block.call(record) + + # Check if any field has data (use safe read to handle plaintext) + next unless fields.any? { |f| safe_read_field(record, f).present? } + + next if dry_run + + begin + # Read plaintext values safely + plaintext_values = {} + fields.each do |field| + value = safe_read_field(record, field) + plaintext_values[field] = value if value.present? + end + + next if plaintext_values.empty? + + # Use a temporary instance to encrypt values (avoids triggering + # validations/callbacks that might read other encrypted fields) + encryptor = model_class.new + plaintext_values.each do |field, value| + encryptor.send("#{field}=", value) + end + + # Extract the encrypted values from the temporary instance + encrypted_attrs = {} + plaintext_values.keys.each do |field| + encrypted_attrs[field] = encryptor.read_attribute_before_type_cast(field) + end + + # Write directly to database, bypassing callbacks/validations + record.update_columns(encrypted_attrs) + updated += 1 + rescue => e + failed << { id: record.id, error: e.class.name, message: e.message } + end + end + end + + { + processed: processed, + updated: updated, + failed_count: failed.size, + failed_samples: failed.take(3) + } + end + + # Safely read a field value, handling both encrypted and plaintext data. + # When encryption is configured but the value is plaintext, the getter + # raises ActiveRecord::Encryption::Errors::Decryption. In this case, + # we fall back to reading the raw database value. + def safe_read_field(record, field) + record.send(field) + rescue ActiveRecord::Encryption::Errors::Decryption + record.read_attribute_before_type_cast(field) + end + + def backfill_sessions(batch_size, dry_run) + processed = 0 + updated = 0 + failed = [] + + Session.order(:id).in_batches(of: batch_size) do |batch| + batch.each do |session| + processed += 1 + next if dry_run + + begin + changes = {} + + # Re-save user_agent to trigger encryption (use safe read for plaintext) + user_agent_value = safe_read_field(session, :user_agent) + if user_agent_value.present? + # Use temporary instance to encrypt + encryptor = Session.new + encryptor.user_agent = user_agent_value + changes[:user_agent] = encryptor.read_attribute_before_type_cast(:user_agent) + end + + # Hash IP address into ip_address_digest if not already done + if session.ip_address.present? && session.ip_address_digest.blank? + changes[:ip_address_digest] = Digest::SHA256.hexdigest(session.ip_address.to_s) + end + + if changes.present? + session.update_columns(changes) + updated += 1 + end + rescue => e + failed << { id: session.id, error: e.class.name, message: e.message } + end + end + end + + { + processed: processed, + updated: updated, + failed_count: failed.size, + failed_samples: failed.take(3) + } + end +end diff --git a/test/encryption_verification_test.rb b/test/encryption_verification_test.rb new file mode 100644 index 000000000..63885a1d0 --- /dev/null +++ b/test/encryption_verification_test.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require "test_helper" + +class EncryptionVerificationTest < ActiveSupport::TestCase + # Skip all tests in this file if encryption is not configured. + # This allows the test suite to pass in environments without encryption keys. + setup do + skip "Encryption not configured" unless User.encryption_ready? + end + + # ============================================================================ + # USER MODEL TESTS + # ============================================================================ + + test "user email is encrypted and can be looked up" do + user = User.create!( + email: "encryption-test@example.com", + password: "password123", + family: families(:dylan_family) + ) + + # Verify we can find by email (deterministic encryption) + found = User.find_by(email: "encryption-test@example.com") + assert_equal user.id, found.id + + # Verify case-insensitive lookup works + found_upper = User.find_by(email: "ENCRYPTION-TEST@EXAMPLE.COM") + assert_equal user.id, found_upper.id + + # Clean up + user.destroy + end + + test "user email uniqueness validation works with encryption" do + user1 = User.create!( + email: "unique-test@example.com", + password: "password123", + family: families(:dylan_family) + ) + + # Should fail uniqueness + user2 = User.new( + email: "unique-test@example.com", + password: "password123", + family: families(:dylan_family) + ) + assert_not user2.valid? + assert user2.errors[:email].any? + + user1.destroy + end + + test "user names are encrypted and retrievable" do + user = users(:family_admin) + original_first = user.first_name + original_last = user.last_name + + # Update names + user.update!(first_name: "EncryptedFirst", last_name: "EncryptedLast") + user.reload + + assert_equal "EncryptedFirst", user.first_name + assert_equal "EncryptedLast", user.last_name + + # Restore + user.update!(first_name: original_first, last_name: original_last) + end + + test "user MFA otp_secret is encrypted" do + user = users(:family_admin) + + # Setup MFA + user.setup_mfa! + assert user.otp_secret.present? + + # Reload and verify we can still read it + user.reload + assert user.otp_secret.present? + + # Verify provisioning URI works + assert user.provisioning_uri.present? + + # Clean up + user.disable_mfa! + end + + test "user unconfirmed_email is encrypted" do + user = users(:family_admin) + original_email = user.email + + # Set unconfirmed email + user.update!(unconfirmed_email: "new-email@example.com") + user.reload + + assert_equal "new-email@example.com", user.unconfirmed_email + + # Clean up + user.update!(unconfirmed_email: nil) + end + + # ============================================================================ + # INVITATION MODEL TESTS + # ============================================================================ + + test "invitation token is encrypted and lookups work" do + invitation = Invitation.create!( + email: "invite-test@example.com", + role: "member", + inviter: users(:family_admin), + family: families(:dylan_family) + ) + + # Token should be present + assert invitation.token.present? + token_value = invitation.token + + # Should be able to find by token + found = Invitation.find_by(token: token_value) + assert_equal invitation.id, found.id + + invitation.destroy + end + + test "invitation email is encrypted and scoped uniqueness works" do + invitation1 = Invitation.create!( + email: "scoped-invite@example.com", + role: "member", + inviter: users(:family_admin), + family: families(:dylan_family) + ) + + # Same email, same family should fail + invitation2 = Invitation.new( + email: "scoped-invite@example.com", + role: "member", + inviter: users(:family_admin), + family: families(:dylan_family) + ) + assert_not invitation2.valid? + + invitation1.destroy + end + + # ============================================================================ + # INVITE CODE MODEL TESTS + # ============================================================================ + + test "invite code token is encrypted and claim works" do + token = InviteCode.generate! + assert token.present? + + # Should be able to claim + result = InviteCode.claim!(token) + assert result + + # Should not be able to claim again (destroyed) + result2 = InviteCode.claim!(token) + assert_nil result2 + end + + test "invite code case-insensitive lookup works" do + invite_code = InviteCode.create! + token = invite_code.token + + # Should find with lowercase + found = InviteCode.find_by(token: token.downcase) + assert_equal invite_code.id, found.id + + invite_code.destroy + end + + # ============================================================================ + # SESSION MODEL TESTS + # ============================================================================ + + test "session user_agent is encrypted" do + Current.user_agent = "Mozilla/5.0 Test Browser" + Current.ip_address = "192.168.1.100" + + begin + session = Session.create!(user: users(:family_admin)) + + assert_equal "Mozilla/5.0 Test Browser", session.user_agent + assert session.ip_address_digest.present? + + # Reload and verify + session.reload + assert_equal "Mozilla/5.0 Test Browser", session.user_agent + + # Verify IP hash is consistent + expected_hash = Digest::SHA256.hexdigest("192.168.1.100") + assert_equal expected_hash, session.ip_address_digest + + session.destroy + ensure + Current.user_agent = nil + Current.ip_address = nil + end + end + + # ============================================================================ + # MOBILE DEVICE MODEL TESTS + # ============================================================================ + + test "mobile device device_id is encrypted and uniqueness works" do + device = MobileDevice.create!( + user: users(:family_admin), + device_id: "test-device-12345", + device_name: "Test iPhone", + device_type: "ios" + ) + + # Should be able to find by device_id + found = MobileDevice.find_by(device_id: "test-device-12345", user: users(:family_admin)) + assert_equal device.id, found.id + + # Same device_id for same user should fail + device2 = MobileDevice.new( + user: users(:family_admin), + device_id: "test-device-12345", + device_name: "Another iPhone", + device_type: "ios" + ) + assert_not device2.valid? + + # Same device_id for different user should work + device3 = MobileDevice.new( + user: users(:family_member), + device_id: "test-device-12345", + device_name: "Their iPhone", + device_type: "ios" + ) + assert device3.valid? + + device.destroy + end + + # ============================================================================ + # PROVIDER ITEM TESTS (if fixtures exist) + # ============================================================================ + + test "lunchflow item credentials and payloads are encrypted" do + skip "No lunchflow items in fixtures" unless LunchflowItem.any? + + item = LunchflowItem.first + original_payload = item.raw_payload + + # Should be able to read + assert item.api_key.present? || item.raw_payload.present? + + # Update payload + item.update!(raw_payload: { test: "data" }) + item.reload + + assert_equal({ "test" => "data" }, item.raw_payload) + + # Restore + item.update!(raw_payload: original_payload) + end + + test "lunchflow account payloads are encrypted" do + skip "No lunchflow accounts in fixtures" unless LunchflowAccount.any? + + account = LunchflowAccount.first + original_payload = account.raw_payload + + # Should be able to read encrypted fields without error + account.reload + assert_nothing_raised { account.raw_payload } + assert_nothing_raised { account.raw_transactions_payload } + + # Update and verify + account.update!(raw_payload: { account_test: "value" }) + account.reload + + assert_equal({ "account_test" => "value" }, account.raw_payload) + + # Restore + account.update!(raw_payload: original_payload) + end + + # ============================================================================ + # DATABASE VERIFICATION TESTS + # ============================================================================ + + test "encrypted fields are not stored as plaintext in database" do + user = User.create!( + email: "plaintext-check@example.com", + password: "password123", + first_name: "PlaintextFirst", + last_name: "PlaintextLast", + family: families(:dylan_family) + ) + + # Query raw database value + raw_email = ActiveRecord::Base.connection.select_value( + User.where(id: user.id).select(:email).to_sql + ) + + # Should NOT be plaintext (should be encrypted blob or different) + assert_not_equal "plaintext-check@example.com", raw_email, + "Email should be encrypted in database, not stored as plaintext" + + user.destroy + end +end diff --git a/test/models/sso_provider_test.rb b/test/models/sso_provider_test.rb index 9613ae255..86021c397 100644 --- a/test/models/sso_provider_test.rb +++ b/test/models/sso_provider_test.rb @@ -80,6 +80,8 @@ class SsoProviderTest < ActiveSupport::TestCase end test "encrypts client_secret" do + skip "Encryption not configured" unless SsoProvider.encryption_ready? + provider = SsoProvider.create!( strategy: "openid_connect", name: "encrypted_test",