Files
sure/test/encryption_verification_test.rb
Oscar c6a240a183 feat(redbark): add australian bank sync (redbark) (#2794)
* add redbark provider integration

- per family api key provider, built like the lunchflow integration
- syncs accounts, balances and transactions from api.redbark.com
- account setup flow, settings panel, locales and routes
- tests and fixtures

* harden redbark integration based on prior provider pr feedback

- use DebugLogEntry.capture for sync/import/unlink failures
- retry 429s and 5xxs with backoff, raise on page cap instead of truncating
- keep raw response bodies out of logs and errors
- not null constraints on account columns, migration base 7.2
- persist ignored flag for skipped accounts so they stop nagging setup
- validate api key on every save, re-arm status on key rotation
- destroy aborts if unlink fails, atomic account create and link
- require_admin on mutating actions, see_other on error redirects
- single grouped query for item account counts
- i18n default connection name, blank password field value
- controller and provider tests

* fix issues found in second review sweep

- add missing syncable scope, without it every family sync raises
- kick off a sync on connection create and on key rotation
- setup dialog fetches accounts inline for fresh connections and shows api errors
- skip balance write when no balance has been fetched yet, never anchor a false zero
- exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected
- detect the server row ceiling and empty pages instead of silently truncating history
- user sync start date only governs the initial backfill, incremental after that
- fetch connections before the per account loop so auth errors propagate once
- drop untemplated index/show/new/edit routes and dead preload/link_accounts actions
- stable dom id on the settings panel so repeat turbo replaces keep working

* skip brokerage connections, found in live testing

- the transactions endpoint 400s for brokerage connections, they belong to /v1/trades
- only import accounts from banking and documents connections
- guard transaction fetches for any legacy linked non banking account

* address review feedback

- treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account
- prune stale pending rows from the snapshot so settled pendings cant come back as duplicates
- block linking a sure account that already has another provider feed
- count setup failures separately from skips and surface an error instead of "all skipped"
- add not nulls on redbark_items name and api key
- enqueue the destroy job after the flag commits, not inside the transaction
- swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks
- tests for window splitting, pending pruning and encrypted payload round trip

* fix issues from convention review

- benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success
- currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default
- merchant ids use truncated sha256 instead of md5
- debug log entries for import failures and account sync scheduling failures

* bound the raw transactions snapshot to the fetch window

- trim raw_transactions_payload to the current fetch window on merge, same as brex
- keep rows without a parseable date, drop settled pendings as before
- surface skipped rows in the aggregate debug log entry with imported/skipped counts
2026-07-26 07:40:25 +02:00

348 lines
9.9 KiB
Ruby

# 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
test "redbark item credentials and payloads are encrypted" do
skip "No redbark items in fixtures" unless RedbarkItem.any?
item = RedbarkItem.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 "redbark account payloads are encrypted" do
skip "No redbark accounts in fixtures" unless RedbarkAccount.any?
account = RedbarkAccount.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