Files
sure/test/controllers/settings/providers_controller_test.rb
ghost 95f6451b39 feat(sync): add Brex provider connections (#1752)
* feat(sync): add Brex provider schema

Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns.

* feat(sync): add Brex provider core

Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data.

* feat(sync): add Brex import pipeline

Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing.

* feat(sync): add Brex connection flows

Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling.

* test(sync): cover Brex provider workflows

Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows.

* fix(sync): align Brex API edge cases

Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage.

* fix(sync): harden Brex provider integration

Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases.

* test(sync): avoid Brex secret-shaped fixtures

* refactor(sync): extract Brex account flows

* fix(sync): address Brex provider review feedback

* fix(sync): address Brex review follow-ups

Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback.

Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation.

* refactor(sync): split Brex account flow controllers

Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable.

Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync.

* fix(sync): address Brex CodeRabbit review

* fix(sync): address Brex follow-up review

* fix(sync): address Brex review follow-ups

* fix(sync): address Brex sync review findings

* fix(sync): polish Brex review copy and errors

* fix(sync): register Brex provider health

* fix(sync): polish Brex bank sync presentation

* fix(sync): address Brex review follow-ups

* fix(sync): tighten Brex setup params

* test(api): stabilize usage rate-limit window

* fix(sync): polish Brex setup flow nits

* fix(sync): harden Brex setup params

* fix(sync): finalize Brex review cleanup

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 18:13:48 +02:00

462 lines
15 KiB
Ruby

require "test_helper"
class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
setup do
sign_in users(:family_admin)
# Ensure provider adapters are loaded for all tests
Provider::Factory.ensure_adapters_loaded
end
test "GET /settings/bank_sync redirects permanently to /settings/providers" do
get "/settings/bank_sync"
assert_redirected_to "/settings/providers"
assert_equal 301, response.status
end
test "can access when self hosting is disabled (managed mode)" do
Rails.configuration.stubs(:app_mode).returns("managed".inquiry)
get settings_providers_url
assert_response :success
patch settings_providers_url, params: { setting: { plaid_client_id: "test123" } }
assert_redirected_to settings_providers_url
end
test "should get show when self hosting is enabled" do
with_self_hosting do
get settings_providers_url
assert_response :success
end
end
test "shows configured Brex connections in bank sync settings" do
get settings_providers_url
assert_response :success
assert_includes response.body, "Brex"
assert_includes response.body, "Test Brex Connection"
assert_includes response.body, "brex-providers-panel"
end
test "shows Brex as available when family has no Brex connections" do
sign_in users(:empty)
get settings_providers_url
assert_response :success
assert_includes response.body, "Brex"
assert_includes response.body, I18n.t("settings.providers.taglines.brex")
assert_includes response.body, connect_form_settings_providers_path(provider_key: "brex")
refute_includes response.body, "Test Brex Connection"
end
test "correctly identifies declared vs dynamic fields" do
# All current provider fields are dynamic, but the logic should correctly
# distinguish between declared and dynamic fields
with_self_hosting do
# plaid_client_id is a dynamic field (not defined in Setting)
refute Setting.singleton_class.method_defined?(:plaid_client_id=),
"plaid_client_id= should NOT be defined on Setting's singleton class"
# openai_model IS a declared field (defined in Setting)
# but it's not a provider field, so it won't go through this controller
assert Setting.singleton_class.method_defined?(:openai_model=),
"openai_model= should be defined on Setting's singleton class"
end
end
test "updates dynamic provider fields using batch update" do
# plaid_client_id is a dynamic field, stored as an individual entry
with_self_hosting do
# Clear any existing plaid settings
Setting["plaid_client_id"] = nil
patch settings_providers_url, params: {
setting: { plaid_client_id: "test_client_id" }
}
assert_redirected_to settings_providers_url
assert_equal "test_client_id", Setting["plaid_client_id"]
end
end
test "batches multiple dynamic fields from same provider atomically" do
# Test that multiple fields from Plaid are updated as individual entries
with_self_hosting do
# Clear existing fields
Setting["plaid_client_id"] = nil
Setting["plaid_secret"] = nil
Setting["plaid_environment"] = nil
patch settings_providers_url, params: {
setting: {
plaid_client_id: "new_client_id",
plaid_secret: "new_secret",
plaid_environment: "production"
}
}
assert_redirected_to settings_providers_url
# All three should be present as individual entries
assert_equal "new_client_id", Setting["plaid_client_id"]
assert_equal "new_secret", Setting["plaid_secret"]
assert_equal "production", Setting["plaid_environment"]
end
end
test "batches dynamic fields from multiple providers atomically" do
# Test that fields from different providers are stored as individual entries
with_self_hosting do
# Clear existing fields
Setting["plaid_client_id"] = nil
Setting["plaid_secret"] = nil
Setting["plaid_eu_client_id"] = nil
Setting["plaid_eu_secret"] = nil
patch settings_providers_url, params: {
setting: {
plaid_client_id: "plaid_client",
plaid_secret: "plaid_secret",
plaid_eu_client_id: "plaid_eu_client",
plaid_eu_secret: "plaid_eu_secret"
}
}
assert_redirected_to settings_providers_url
# All fields should be present
assert_equal "plaid_client", Setting["plaid_client_id"]
assert_equal "plaid_secret", Setting["plaid_secret"]
assert_equal "plaid_eu_client", Setting["plaid_eu_client_id"]
assert_equal "plaid_eu_secret", Setting["plaid_eu_secret"]
end
end
test "preserves existing dynamic fields when updating new ones" do
# Test that updating some fields doesn't overwrite other existing fields
with_self_hosting do
# Set initial fields
Setting["existing_field_1"] = "value1"
Setting["plaid_client_id"] = "old_client_id"
# Update one field and add a new one
patch settings_providers_url, params: {
setting: {
plaid_client_id: "new_client_id",
plaid_secret: "new_secret"
}
}
assert_redirected_to settings_providers_url
# Existing unrelated field should still be there
assert_equal "value1", Setting["existing_field_1"]
# Updated field should have new value
assert_equal "new_client_id", Setting["plaid_client_id"]
# New field should be added
assert_equal "new_secret", Setting["plaid_secret"]
end
end
test "skips placeholder values for secret fields" do
with_self_hosting do
# Set an initial secret value
Setting["plaid_secret"] = "real_secret"
# Try to update with placeholder
patch settings_providers_url, params: {
setting: {
plaid_client_id: "new_client_id",
plaid_secret: "********" # Placeholder value
}
}
assert_redirected_to settings_providers_url
# Client ID should be updated
assert_equal "new_client_id", Setting["plaid_client_id"]
# Secret should remain unchanged
assert_equal "real_secret", Setting["plaid_secret"]
end
end
test "converts blank values to nil and removes from dynamic_fields" do
with_self_hosting do
# Set initial values
Setting["plaid_client_id"] = "old_value"
assert_equal "old_value", Setting["plaid_client_id"]
assert Setting.key?("plaid_client_id")
patch settings_providers_url, params: {
setting: { plaid_client_id: " " } # Blank string with spaces
}
assert_redirected_to settings_providers_url
assert_nil Setting["plaid_client_id"]
# Entry should be removed, not just set to nil
refute Setting.key?("plaid_client_id"),
"nil values should delete the entry"
end
end
test "handles sequential updates to different dynamic fields safely" do
# This test simulates what would happen if two requests tried to update
# different dynamic fields sequentially. With individual entries,
# all changes should be preserved without conflicts.
with_self_hosting do
Setting["existing_field"] = "existing_value"
# Simulate first request updating plaid fields
patch settings_providers_url, params: {
setting: {
plaid_client_id: "client_id_1",
plaid_secret: "secret_1"
}
}
# Existing field should still be there
assert_equal "existing_value", Setting["existing_field"]
# New fields should be added
assert_equal "client_id_1", Setting["plaid_client_id"]
assert_equal "secret_1", Setting["plaid_secret"]
# Simulate second request updating different plaid fields
patch settings_providers_url, params: {
setting: {
plaid_environment: "production"
}
}
# All previously set fields should still be there
assert_equal "existing_value", Setting["existing_field"]
assert_equal "client_id_1", Setting["plaid_client_id"]
assert_equal "secret_1", Setting["plaid_secret"]
assert_equal "production", Setting["plaid_environment"]
end
end
test "only processes valid configuration fields" do
with_self_hosting do
# Try to update a field that doesn't exist in any provider configuration
patch settings_providers_url, params: {
setting: {
plaid_client_id: "valid_field",
fake_field_that_does_not_exist: "should_be_ignored"
}
}
assert_redirected_to settings_providers_url
# Valid field should be updated
assert_equal "valid_field", Setting["plaid_client_id"]
# Invalid field should not be stored
assert_nil Setting["fake_field_that_does_not_exist"]
end
end
test "calls reload_configuration on updated providers" do
with_self_hosting do
# Mock the adapter class to verify reload_configuration is called
Provider::PlaidAdapter.expects(:reload_configuration).once
patch settings_providers_url, params: {
setting: { plaid_client_id: "new_client_id" }
}
assert_redirected_to settings_providers_url
end
end
test "reloads configuration for multiple providers when updated" do
with_self_hosting do
# Both Plaid providers (US and EU) should have their configuration reloaded
Provider::PlaidAdapter.expects(:reload_configuration).once
Provider::PlaidEuAdapter.expects(:reload_configuration).once
patch settings_providers_url, params: {
setting: {
plaid_client_id: "plaid_client",
plaid_eu_client_id: "plaid_eu_client"
}
}
assert_redirected_to settings_providers_url
end
end
test "logs errors when update fails" do
with_self_hosting do
# Test that errors during update are properly logged and handled gracefully
# We'll force an error by making the []= method raise
Setting.expects(:[]=).with("plaid_client_id", "test").raises(StandardError.new("Database error")).once
# Mock logger to verify error is logged (pin both the exception class
# name and the message so a regression that drops one still fails).
Rails.logger.expects(:error).with(regexp_matches(/Failed to update provider settings: StandardError - Database error/)).once
patch settings_providers_url, params: {
setting: { plaid_client_id: "test" }
}
# Controller should handle the error gracefully with generic message (no internal details)
assert_response :unprocessable_entity
assert_equal "Failed to update provider settings. Please try again.", flash[:alert]
end
end
test "shows no changes message when no fields are updated" do
with_self_hosting do
# Only send a secret field with placeholder value (which gets skipped)
Setting["plaid_secret"] = "existing_secret"
patch settings_providers_url, params: {
setting: { plaid_secret: "********" }
}
assert_redirected_to settings_providers_url
assert_equal "No changes were made", flash[:notice]
end
end
test "POST sync_all enqueues SyncAllProvidersJob" do
SimplefinItem.create!(
family: families(:dylan_family),
name: "Test SimpleFIN Sync All",
access_url: "https://bridge.simplefin.org/simplefin/access"
)
families(:dylan_family).update_column(:last_sync_all_attempted_at, nil)
assert_enqueued_with(job: SyncAllProvidersJob) do
post sync_all_settings_providers_path
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Syncing all connected providers/i, response.body)
end
test "POST sync_all respects recent sync throttle" do
families(:dylan_family).update_column(:last_sync_all_attempted_at, Time.current)
assert_no_enqueued_jobs only: SyncAllProvidersJob do
post sync_all_settings_providers_path
end
assert_redirected_to settings_providers_path
assert_equal I18n.t("settings.providers.sync_all_recently"), flash[:notice]
end
test "POST sync for simplefin without an active Simplefin sync enqueues SyncJob" do
item = SimplefinItem.create!(
family: families(:dylan_family),
name: "Test SimpleFIN Per Row Sync",
access_url: "https://bridge.simplefin.org/simplefin/access"
)
Sync.where(syncable_type: "SimplefinItem", syncable_id: item.id).delete_all
assert_enqueued_jobs 1, only: SyncJob do
post sync_provider_settings_providers_path(provider_key: "simplefin")
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Sync started/i, response.body)
end
test "POST sync for brex without an active Brex sync enqueues SyncJob" do
item = brex_items(:one)
Sync.where(syncable_type: "BrexItem", syncable_id: item.id).delete_all
assert_enqueued_jobs 1, only: SyncJob do
post sync_provider_settings_providers_path(provider_key: "brex")
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Sync started/i, response.body)
end
test "GET show includes Interactive Brokers in bank sync providers" do
get settings_providers_url
assert_response :success
assert_match(/Interactive Brokers/i, response.body)
assert_match(/Flex Query/i, response.body)
end
test "GET connect_form renders Interactive Brokers panel" do
get connect_form_settings_providers_path(provider_key: "ibkr")
assert_response :success
assert_match(/Interactive Brokers/i, response.body)
assert_match(/Query ID/i, response.body)
end
test "POST sync for ibkr without an active Ibkr sync enqueues SyncJob" do
item = ibkr_items(:configured_item)
Sync.where(syncable_type: "IbkrItem", syncable_id: item.id).delete_all
assert_enqueued_jobs 1, only: SyncJob do
post sync_provider_settings_providers_path(provider_key: "ibkr")
end
assert_redirected_to settings_providers_path
follow_redirect!
assert_response :success
assert_match(/Sync started/i, response.body)
end
test "non-admin users cannot update providers" do
with_self_hosting do
sign_in users(:family_member)
patch settings_providers_url, params: {
setting: { plaid_client_id: "test" }
}
assert_redirected_to root_path
assert_equal "Not authorized", flash[:alert]
# Value should not have changed
assert_nil Setting["plaid_client_id"]
end
end
test "uses singleton_class method_defined to detect declared fields" do
with_self_hosting do
# This test verifies the difference between respond_to? and singleton_class.method_defined?
# openai_model is a declared field
assert Setting.singleton_class.method_defined?(:openai_model=),
"openai_model= should be defined on Setting's singleton class"
assert Setting.respond_to?(:openai_model=),
"respond_to? should return true for declared field"
# plaid_client_id is a dynamic field
refute Setting.singleton_class.method_defined?(:plaid_client_id=),
"plaid_client_id= should NOT be defined on Setting's singleton class"
refute Setting.respond_to?(:plaid_client_id=),
"respond_to? should return false for dynamic field"
# Both methods currently return the same result, but singleton_class.method_defined?
# is more explicit and reliable for checking if a method is actually defined
end
end
end