mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 20:22:16 +00:00
* feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow Squashed from 16 commits on snaptrade-oauth-apps for a clean rebase onto current upstream/main ahead of opening a PR. * fix(snaptrade): address PR #2747 review feedback on OAuth PKCE flow - Remove unreachable dead-code guard in import_latest_snaptrade_data - Guard apply_oauth_tokens! against a malformed payload missing access_token - Wrap token endpoint network errors in ApiError and retry like data calls - Remove unused Provider::Snaptrade#revoke_token! instance method - Preserve return_to/accountable_type through the SnapTrade portal callback so the account-linking flow no longer drops users back to accounts_path - Show the real absolute OAuth callback URL in self-hosted setup instructions - Refresh brakeman.ignore fingerprint for the connect redirect after the return_to/accountable_type params were added Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8SCCmKX6RphB5E73WSUQQ * fix(snaptrade): don't retry non-idempotent OAuth/API requests CodeRabbit flagged that Provider::Snaptrade retried OAuth token exchanges/refreshes and all API POST/DELETE calls (get_connection_url, delete_connection) after timeouts/connection failures. If the response is lost after SnapTrade already consumed a single-use auth code, rotated the refresh token, or applied a POST/DELETE, replaying the request either fails with invalid_grant on a token that actually succeeded, or risks duplicate side effects. Retries are now limited to GET requests; OAuth token requests and non-GET API calls translate a network failure straight into an ApiError without replay. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL * fix(snaptrade): stop querying non-deterministically encrypted token via empty-string compare CodeRabbit flagged that the syncable scope's where.not(oauth_access_token: [nil, ""]) re-encrypts "" with a random IV on every query, so the "" comparison can never match a stored ciphertext and is a silent no-op. No code path ever persists oauth_access_token as "" (only nil or a real token via apply_oauth_tokens!), so the exclusion is unnecessary -- narrowed the scope to a plain NULL check, which encryption handles transparently since nil is never encrypted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
515 lines
18 KiB
Ruby
515 lines
18 KiB
Ruby
require "test_helper"
|
|
|
|
class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
|
|
include ActiveJob::TestHelper
|
|
|
|
setup do
|
|
ensure_tailwind_build
|
|
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 "sync all control submits with POST" do
|
|
SimplefinItem.create!(
|
|
family: families(:dylan_family),
|
|
name: "Test SimpleFIN Sync All Control",
|
|
access_url: "https://bridge.simplefin.org/simplefin/access"
|
|
)
|
|
|
|
with_self_hosting do
|
|
get settings_providers_url
|
|
assert_response :success
|
|
assert_select "form[action=?][method=?]", sync_all_settings_providers_path, "post"
|
|
end
|
|
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 "GET connect_form for snaptrade shows OAuth setup instructions when instance is not configured" do
|
|
Provider::Snaptrade.stubs(:oauth_configured?).returns(false)
|
|
|
|
get connect_form_settings_providers_path(provider_key: "snaptrade")
|
|
|
|
assert_response :success
|
|
assert_includes response.body, I18n.t("providers.snaptrade.oauth_setup_step_3")
|
|
refute_includes response.body, I18n.t("providers.snaptrade.oauth_connect_button")
|
|
refute_includes response.body, I18n.t("providers.snaptrade.oauth_status_ready")
|
|
end
|
|
|
|
test "GET connect_form for snaptrade shows connect CTA when configured but item is not authorized" do
|
|
sign_in users(:empty)
|
|
Provider::Snaptrade.stubs(:oauth_configured?).returns(true)
|
|
|
|
get connect_form_settings_providers_path(provider_key: "snaptrade")
|
|
|
|
assert_response :success
|
|
assert_includes response.body, I18n.t("providers.snaptrade.oauth_connect_button")
|
|
assert_includes response.body, I18n.t("providers.snaptrade.oauth_status_ready")
|
|
refute_includes response.body, I18n.t("providers.snaptrade.oauth_status_authorized")
|
|
refute_includes response.body, I18n.t("providers.snaptrade.oauth_reauthorize_button")
|
|
end
|
|
|
|
test "GET connect_form for snaptrade shows authorized status and reauthorize CTA when item is connected" do
|
|
# Default signed-in user (family_admin) belongs to dylan_family, which owns
|
|
# the oauth-authorized `configured_item` fixture.
|
|
Provider::Snaptrade.stubs(:oauth_configured?).returns(true)
|
|
|
|
get connect_form_settings_providers_path(provider_key: "snaptrade")
|
|
|
|
assert_response :success
|
|
assert_includes response.body, I18n.t("providers.snaptrade.oauth_status_authorized")
|
|
assert_includes response.body, I18n.t("providers.snaptrade.oauth_reauthorize_button")
|
|
assert_includes response.body, I18n.t("providers.snaptrade.manage_connections")
|
|
refute_includes response.body, I18n.t("providers.snaptrade.oauth_connect_button")
|
|
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
|