Initial security fixes (#461)

* Initial sec

* Update PII fields

* FIX add tests

* FIX safely read plaintext data on rake backfill

* Update user.rb

* FIX tests

* encryption_ready? block

* Test conditional to encryption on

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2026-01-23 22:05:28 +01:00
committed by GitHub
parent 71f10c5e4a
commit 696ff0966b
21 changed files with 645 additions and 55 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

6
db/schema.rb generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",