feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)

* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments

* fix: NoMethodError by defining SSL helper methods before configure block executes

* refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks

* refactor: improve SSL configuration robustness and error detection accuracy

* fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs

* fix:  Fix SSL concern indentation and stub Simplefin POST correctly in tests

* fix: normalize ssl_verify to always return boolean instead of nil

* fix: solve failing SimpleFin test

* refactor:  trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback.

* fix: added require ileutils in the initializer and require ostruct in the test file.

* fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
This commit is contained in:
BitToby
2026-02-06 14:04:03 -03:00
committed by GitHub
parent 87117445fe
commit ba6e286b41
20 changed files with 824 additions and 42 deletions

View File

@@ -48,3 +48,36 @@ LANGFUSE_HOST = https://cloud.langfuse.com
# Set to `true` to get error messages rendered in the /chats UI # Set to `true` to get error messages rendered in the /chats UI
AI_DEBUG_MODE = AI_DEBUG_MODE =
# =============================================================================
# SSL/TLS Configuration for Self-Signed Certificates
# =============================================================================
# Use these settings when connecting to services with self-signed or internal
# CA certificates (e.g., self-hosted Keycloak, Authentik, or AI endpoints).
#
# SSL_CA_FILE: Path to custom CA certificate file (PEM format)
# - The certificate that signed your server's SSL certificate
# - Must be readable by the application
# - Will be validated at startup
# SSL_CA_FILE = /certs/my-ca.crt
#
# SSL_VERIFY: Enable/disable SSL certificate verification
# - Default: true (verification enabled)
# - Set to "false" ONLY for development/testing
# - WARNING: Disabling removes protection against man-in-the-middle attacks
# SSL_VERIFY = true
#
# SSL_DEBUG: Enable verbose SSL logging for troubleshooting
# - Default: false
# - When enabled, logs detailed SSL connection information
# - Useful for diagnosing certificate issues
# SSL_DEBUG = false
#
# Example docker-compose.yml configuration:
# services:
# app:
# environment:
# SSL_CA_FILE: /certs/my-ca.crt
# SSL_DEBUG: "true"
# volumes:
# - ./my-ca.crt:/certs/my-ca.crt:ro

View File

@@ -1,4 +1,6 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
extend SslConfigurable
before_action :set_session, only: :destroy before_action :set_session, only: :destroy
skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start] skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start]
@@ -305,7 +307,7 @@ class SessionsController < ApplicationController
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present? if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
begin begin
discovery_url = discovery_url_for(provider_config[:issuer]) discovery_url = discovery_url_for(provider_config[:issuer])
response = Faraday.get(discovery_url) do |req| response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
req.options.timeout = 5 req.options.timeout = 5
req.options.open_timeout = 3 req.options.open_timeout = 3
end end

View File

@@ -0,0 +1,126 @@
# frozen_string_literal: true
# Provides centralized SSL configuration for HTTP clients.
#
# This module enables support for self-signed certificates in self-hosted
# environments by reading configuration from Rails.configuration.x.ssl.
#
# Features:
# - Custom CA certificate support for self-signed certificates
# - Optional SSL verification bypass (for development/testing only)
# - Debug logging for troubleshooting SSL issues
#
# Usage (extend for class methods — the only supported pattern):
# class MyHttpClient
# extend SslConfigurable
#
# def self.make_request
# Faraday.new(url, ssl: faraday_ssl_options) { |f| ... }
# end
# end
#
# Environment Variables (configured in config/initializers/00_ssl.rb):
# SSL_CA_FILE - Path to custom CA certificate file (PEM format)
# SSL_VERIFY - Set to "false" to disable SSL verification
# SSL_DEBUG - Set to "true" to enable verbose SSL logging
module SslConfigurable
# Returns SSL options hash for Faraday connections
#
# @return [Hash] SSL options for Faraday
# @example
# Faraday.new(url, ssl: faraday_ssl_options) do |f|
# f.request :json
# f.response :raise_error
# end
def faraday_ssl_options
options = {}
options[:verify] = ssl_verify?
if ssl_ca_file.present?
options[:ca_file] = ssl_ca_file
log_ssl_debug("Faraday SSL: Using custom CA file: #{ssl_ca_file}")
end
log_ssl_debug("Faraday SSL: Verification disabled") unless ssl_verify?
log_ssl_debug("Faraday SSL options: #{options.inspect}") if options.present?
options
end
# Returns SSL options hash for HTTParty requests
#
# @return [Hash] SSL options for HTTParty
# @example
# class MyProvider
# include HTTParty
# extend SslConfigurable
# default_options.merge!(httparty_ssl_options)
# end
def httparty_ssl_options
options = { verify: ssl_verify? }
if ssl_ca_file.present?
options[:ssl_ca_file] = ssl_ca_file
log_ssl_debug("HTTParty SSL: Using custom CA file: #{ssl_ca_file}")
end
log_ssl_debug("HTTParty SSL: Verification disabled") unless ssl_verify?
options
end
# Returns SSL verify mode for Net::HTTP
#
# @return [Integer] OpenSSL verify mode constant (VERIFY_PEER or VERIFY_NONE)
# @example
# http = Net::HTTP.new(uri.host, uri.port)
# http.use_ssl = true
# http.verify_mode = net_http_verify_mode
# http.ca_file = ssl_ca_file if ssl_ca_file.present?
def net_http_verify_mode
mode = ssl_verify? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
log_ssl_debug("Net::HTTP verify mode: #{mode == OpenSSL::SSL::VERIFY_PEER ? 'VERIFY_PEER' : 'VERIFY_NONE'}")
mode
end
# Returns CA file path if configured
#
# @return [String, nil] Path to CA file or nil if not configured
def ssl_ca_file
ssl_configuration.ca_file
end
# Returns whether SSL verification is enabled
# nil or true both mean verification is enabled; only explicit false disables it
#
# @return [Boolean] true if SSL verification is enabled
def ssl_verify?
ssl_configuration.verify != false
end
# Returns whether SSL debug logging is enabled
#
# @return [Boolean] true if debug logging is enabled
def ssl_debug?
ssl_configuration.debug == true
end
private
# Returns the SSL configuration from Rails
#
# @return [ActiveSupport::OrderedOptions] SSL configuration
def ssl_configuration
Rails.configuration.x.ssl
end
# Logs a debug message if SSL debug mode is enabled
#
# @param message [String] Message to log
def log_ssl_debug(message)
return unless ssl_debug?
Rails.logger.debug("[SSL Debug] #{message}")
end
end

View File

@@ -1,9 +1,30 @@
class Eval::Langfuse::Client class Eval::Langfuse::Client
extend SslConfigurable
BASE_URLS = { BASE_URLS = {
us: "https://us.cloud.langfuse.com/api/public", us: "https://us.cloud.langfuse.com/api/public",
eu: "https://cloud.langfuse.com/api/public" eu: "https://cloud.langfuse.com/api/public"
}.freeze }.freeze
# OpenSSL 3.x version threshold for CRL workaround
# See: https://github.com/ruby/openssl/issues/619
OPENSSL_3_VERSION = 0x30000000
# CRL-related OpenSSL error codes that can be safely bypassed
# These errors occur when CRL (Certificate Revocation List) is unavailable
def self.crl_errors
@crl_errors ||= begin
errors = [
OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL,
OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED,
OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
]
# V_ERR_UNABLE_TO_GET_CRL_ISSUER may not exist in all OpenSSL versions
errors << OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER if defined?(OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER)
errors.freeze
end
end
class Error < StandardError; end class Error < StandardError; end
class ConfigurationError < Error; end class ConfigurationError < Error; end
class ApiError < Error class ApiError < Error
@@ -176,12 +197,24 @@ class Eval::Langfuse::Client
http.read_timeout = 30 http.read_timeout = 30
http.open_timeout = 10 http.open_timeout = 10
# Fix for OpenSSL 3.x CRL checking issues # Apply SSL configuration from centralized config
http.verify_mode = self.class.net_http_verify_mode
http.ca_file = self.class.ssl_ca_file if self.class.ssl_ca_file.present?
# Fix for OpenSSL 3.x CRL checking issues (only when verification is enabled)
# See: https://github.com/ruby/openssl/issues/619 # See: https://github.com/ruby/openssl/issues/619
http.verify_mode = OpenSSL::SSL::VERIFY_PEER # Only bypass CRL-specific errors, not all certificate verification
if OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30000000 if self.class.ssl_verify? && OpenSSL::OPENSSL_VERSION_NUMBER >= OPENSSL_3_VERSION
# Disable CRL checking which can fail on some certificates crl_error_codes = self.class.crl_errors
http.verify_callback = ->(_preverify_ok, _store_ctx) { true } http.verify_callback = ->(preverify_ok, store_ctx) {
# Bypass only CRL-specific errors (these fail when CRL is unavailable)
# For all other errors, preserve the original verification result
if crl_error_codes.include?(store_ctx.error)
true
else
preverify_ok
end
}
end end
response = http.request(request) response = http.request(request)

View File

@@ -1,4 +1,7 @@
class Provider::Coinbase class Provider::Coinbase
include HTTParty
extend SslConfigurable
class Error < StandardError; end class Error < StandardError; end
class AuthenticationError < Error; end class AuthenticationError < Error; end
class RateLimitError < Error; end class RateLimitError < Error; end
@@ -7,6 +10,9 @@ class Provider::Coinbase
# CDP API base URL # CDP API base URL
API_BASE_URL = "https://api.coinbase.com".freeze API_BASE_URL = "https://api.coinbase.com".freeze
base_uri API_BASE_URL
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
attr_reader :api_key, :api_secret attr_reader :api_key, :api_secret
def initialize(api_key:, api_secret:) def initialize(api_key:, api_secret:)
@@ -57,7 +63,8 @@ class Provider::Coinbase
# Get spot price for a currency pair (e.g., "BTC-USD") # Get spot price for a currency pair (e.g., "BTC-USD")
# This is a public endpoint that doesn't require authentication # This is a public endpoint that doesn't require authentication
def get_spot_price(currency_pair) def get_spot_price(currency_pair)
response = HTTParty.get("#{API_BASE_URL}/v2/prices/#{currency_pair}/spot", timeout: 10) # Use self.class.get to inherit class-level SSL and timeout defaults
response = self.class.get("/v2/prices/#{currency_pair}/spot", timeout: 10)
handle_response(response)["data"] handle_response(response)["data"]
rescue => e rescue => e
Rails.logger.warn("Coinbase: Failed to fetch spot price for #{currency_pair}: #{e.message}") Rails.logger.warn("Coinbase: Failed to fetch spot price for #{currency_pair}: #{e.message}")
@@ -78,13 +85,13 @@ class Provider::Coinbase
private private
def get(path, params: {}) def get(path, params: {})
url = "#{API_BASE_URL}#{path}" url = path
url += "?#{params.to_query}" if params.any? url += "?#{params.to_query}" if params.any?
response = HTTParty.get( # Use self.class.get to inherit class-level SSL and timeout defaults
response = self.class.get(
url, url,
headers: auth_headers("GET", path), headers: auth_headers("GET", path)
timeout: 30
) )
handle_response(response) handle_response(response)
@@ -101,16 +108,14 @@ class Provider::Coinbase
uri = URI.parse(next_uri) uri = URI.parse(next_uri)
current_path = uri.path current_path = uri.path
current_path += "?#{uri.query}" if uri.query current_path += "?#{uri.query}" if uri.query
url = "#{API_BASE_URL}#{current_path}"
else else
current_path = path current_path = path
url = "#{API_BASE_URL}#{path}"
end end
response = HTTParty.get( # Use self.class.get to inherit class-level SSL and timeout defaults
url, response = self.class.get(
headers: auth_headers("GET", current_path.split("?").first), current_path,
timeout: 30 headers: auth_headers("GET", current_path.split("?").first)
) )
data = handle_response(response) data = handle_response(response)

View File

@@ -2,6 +2,7 @@
# Handles authentication and requests to the CoinStats OpenAPI. # Handles authentication and requests to the CoinStats OpenAPI.
class Provider::Coinstats < Provider class Provider::Coinstats < Provider
include HTTParty include HTTParty
extend SslConfigurable
# Subclass so errors caught in this provider are raised as Provider::Coinstats::Error # Subclass so errors caught in this provider are raised as Provider::Coinstats::Error
Error = Class.new(Provider::Error) Error = Class.new(Provider::Error)
@@ -9,7 +10,7 @@ class Provider::Coinstats < Provider
BASE_URL = "https://openapiv1.coinstats.app" BASE_URL = "https://openapiv1.coinstats.app"
headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)" headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
attr_reader :api_key attr_reader :api_key

View File

@@ -2,11 +2,12 @@ require "cgi"
class Provider::EnableBanking class Provider::EnableBanking
include HTTParty include HTTParty
extend SslConfigurable
BASE_URL = "https://api.enablebanking.com".freeze BASE_URL = "https://api.enablebanking.com".freeze
headers "User-Agent" => "Sure Finance Enable Banking Client" headers "User-Agent" => "Sure Finance Enable Banking Client"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
attr_reader :application_id, :private_key attr_reader :application_id, :private_key

View File

@@ -1,8 +1,9 @@
class Provider::Lunchflow class Provider::Lunchflow
include HTTParty include HTTParty
extend SslConfigurable
headers "User-Agent" => "Sure Finance Lunch Flow Client" headers "User-Agent" => "Sure Finance Lunch Flow Client"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
attr_reader :api_key, :base_url attr_reader :api_key, :base_url

View File

@@ -1,8 +1,9 @@
class Provider::Mercury class Provider::Mercury
include HTTParty include HTTParty
extend SslConfigurable
headers "User-Agent" => "Sure Finance Mercury Client" headers "User-Agent" => "Sure Finance Mercury Client"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
attr_reader :token, :base_url attr_reader :token, :base_url

View File

@@ -5,9 +5,10 @@ class Provider::Simplefin
# These are centralized in `Rails.configuration.x.simplefin.*` via # These are centralized in `Rails.configuration.x.simplefin.*` via
# `config/initializers/simplefin.rb`. # `config/initializers/simplefin.rb`.
include HTTParty include HTTParty
extend SslConfigurable
headers "User-Agent" => "Sure Finance SimpleFin Client" headers "User-Agent" => "Sure Finance SimpleFin Client"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
# Retry configuration for transient network failures # Retry configuration for transient network failures
MAX_RETRIES = 3 MAX_RETRIES = 3
@@ -34,8 +35,9 @@ class Provider::Simplefin
# Use retry logic for transient network failures during token claim # Use retry logic for transient network failures during token claim
# Claim should be fast; keep request-path latency bounded. # Claim should be fast; keep request-path latency bounded.
# Use self.class.post to inherit class-level SSL and timeout defaults
response = with_retries("POST /claim", max_retries: 1, sleep: false) do response = with_retries("POST /claim", max_retries: 1, sleep: false) do
HTTParty.post(claim_url, timeout: 15) self.class.post(claim_url, timeout: 15)
end end
case response.code case response.code
@@ -71,8 +73,9 @@ class Provider::Simplefin
# The access URL already contains HTTP Basic Auth credentials # The access URL already contains HTTP Basic Auth credentials
# Use retry logic with exponential backoff for transient network failures # Use retry logic with exponential backoff for transient network failures
# Use self.class.get to inherit class-level SSL and timeout defaults
response = with_retries("GET /accounts") do response = with_retries("GET /accounts") do
HTTParty.get(accounts_url) self.class.get(accounts_url)
end end
case response.code case response.code
@@ -98,7 +101,8 @@ class Provider::Simplefin
end end
def get_info(base_url) def get_info(base_url)
response = HTTParty.get("#{base_url}/info") # Use self.class.get to inherit class-level SSL and timeout defaults
response = self.class.get("#{base_url}/info")
case response.code case response.code
when 200 when 200

View File

@@ -1,5 +1,6 @@
class Provider::TwelveData < Provider class Provider::TwelveData < Provider
include ExchangeRateConcept, SecurityConcept include ExchangeRateConcept, SecurityConcept
extend SslConfigurable
# Subclass so errors caught in this provider are raised as Provider::TwelveData::Error # Subclass so errors caught in this provider are raised as Provider::TwelveData::Error
Error = Class.new(Provider::Error) Error = Class.new(Provider::Error)
@@ -234,7 +235,7 @@ class Provider::TwelveData < Provider
end end
def client def client
@client ||= Faraday.new(url: base_url) do |faraday| @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
faraday.request(:retry, { faraday.request(:retry, {
max: 2, max: 2,
interval: 0.05, interval: 0.05,

View File

@@ -1,5 +1,6 @@
class Provider::YahooFinance < Provider class Provider::YahooFinance < Provider
include ExchangeRateConcept, SecurityConcept include ExchangeRateConcept, SecurityConcept
extend SslConfigurable
# Subclass so errors caught in this provider are raised as Provider::YahooFinance::Error # Subclass so errors caught in this provider are raised as Provider::YahooFinance::Error
Error = Class.new(Provider::Error) Error = Class.new(Provider::Error)
@@ -494,7 +495,7 @@ class Provider::YahooFinance < Provider
end end
def client def client
@client ||= Faraday.new(url: base_url) do |faraday| @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
faraday.request(:retry, { faraday.request(:retry, {
max: max_retries, max: max_retries,
interval: retry_interval, interval: retry_interval,
@@ -609,7 +610,7 @@ class Provider::YahooFinance < Provider
# Client for authentication requests (no error raising - fc.yahoo.com returns 404 but sets cookie) # Client for authentication requests (no error raising - fc.yahoo.com returns 404 but sets cookie)
def auth_client def auth_client
@auth_client ||= Faraday.new do |faraday| @auth_client ||= Faraday.new(ssl: self.class.faraday_ssl_options) do |faraday|
faraday.headers["User-Agent"] = random_user_agent faraday.headers["User-Agent"] = random_user_agent
faraday.headers["Accept"] = "*/*" faraday.headers["Accept"] = "*/*"
faraday.headers["Accept-Language"] = "en-US,en;q=0.9" faraday.headers["Accept-Language"] = "en-US,en;q=0.9"
@@ -620,7 +621,7 @@ class Provider::YahooFinance < Provider
# Client for authenticated requests (includes cookie header) # Client for authenticated requests (includes cookie header)
def authenticated_client(cookie) def authenticated_client(cookie)
Faraday.new(url: base_url) do |faraday| Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
faraday.request(:retry, { faraday.request(:retry, {
max: max_retries, max: max_retries,
interval: retry_interval, interval: retry_interval,

View File

@@ -2,6 +2,7 @@
class SsoProvider < ApplicationRecord class SsoProvider < ApplicationRecord
include Encryptable include Encryptable
extend SslConfigurable
# Encrypt sensitive credentials if ActiveRecord encryption is configured # Encrypt sensitive credentials if ActiveRecord encryption is configured
if encryption_ready? if encryption_ready?
@@ -116,7 +117,7 @@ class SsoProvider < ApplicationRecord
begin begin
discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration" discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration"
response = Faraday.get(discovery_url) do |req| response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
req.options.timeout = 5 req.options.timeout = 5
req.options.open_timeout = 3 req.options.open_timeout = 3
end end

View File

@@ -2,6 +2,8 @@
# Tests SSO provider configuration by validating discovery endpoints # Tests SSO provider configuration by validating discovery endpoints
class SsoProviderTester class SsoProviderTester
extend SslConfigurable
attr_reader :provider, :result attr_reader :provider, :result
Result = Struct.new(:success?, :message, :details, keyword_init: true) Result = Struct.new(:success?, :message, :details, keyword_init: true)
@@ -34,7 +36,7 @@ class SsoProviderTester
discovery_url = build_discovery_url(provider.issuer) discovery_url = build_discovery_url(provider.issuer)
begin begin
response = Faraday.get(discovery_url) do |req| response = faraday_client.get(discovery_url) do |req|
req.options.timeout = 10 req.options.timeout = 10
req.options.open_timeout = 5 req.options.open_timeout = 5
end end
@@ -146,7 +148,7 @@ class SsoProviderTester
metadata_url = provider.settings&.dig("idp_metadata_url") metadata_url = provider.settings&.dig("idp_metadata_url")
if metadata_url.present? if metadata_url.present?
begin begin
response = Faraday.get(metadata_url) do |req| response = faraday_client.get(metadata_url) do |req|
req.options.timeout = 10 req.options.timeout = 10
req.options.open_timeout = 5 req.options.open_timeout = 5
end end
@@ -198,4 +200,8 @@ class SsoProviderTester
"#{issuer}/.well-known/openid-configuration" "#{issuer}/.well-known/openid-configuration"
end end
end end
def faraday_client
@faraday_client ||= Faraday.new(ssl: self.class.faraday_ssl_options)
end
end end

View File

@@ -0,0 +1,267 @@
# frozen_string_literal: true
require "openssl"
require "fileutils"
# Centralized SSL/TLS configuration for outbound HTTPS connections.
#
# This enables support for self-signed certificates in self-hosted environments
# where servers use internal CAs or self-signed certificates.
#
# Environment Variables:
# SSL_CA_FILE - Path to custom CA certificate file (PEM format)
# SSL_VERIFY - Set to "false" to disable SSL verification (NOT RECOMMENDED for production)
# SSL_DEBUG - Set to "true" to enable verbose SSL logging
#
# Example usage in docker-compose.yml:
# environment:
# SSL_CA_FILE: /certs/my-ca.crt
# volumes:
# - ./my-ca.crt:/certs/my-ca.crt:ro
#
# Security Warning:
# Disabling SSL verification (SSL_VERIFY=false) removes protection against
# man-in-the-middle attacks. Only use this for development/testing environments.
#
# IMPORTANT: When a valid SSL_CA_FILE is provided, this initializer sets the
# SSL_CERT_FILE environment variable to a combined CA bundle (system CAs + custom CA).
# This is a *global* side-effect that affects ALL SSL connections in the Ruby process,
# including gems that do not go through SslConfigurable (e.g. openid_connect).
# This is intentional — it ensures OIDC discovery, webhook callbacks, and any other
# outbound HTTPS connection trusts both public CAs and the user's custom CA.
# Path for the combined CA bundle file (predictable location for debugging)
COMBINED_CA_BUNDLE_PATH = Rails.root.join("tmp", "ssl_ca_bundle.pem").freeze
# Boot-time helper for SSL certificate validation and bundle creation.
#
# This is intentionally a standalone module (not nested under SslConfigurable)
# because SslConfigurable is autoloaded by Zeitwerk from app/models/concerns/.
# Reopening that module here at boot time would register the constant before
# Zeitwerk, preventing the real concern (with httparty_ssl_options, etc.) from
# ever being loaded — causing NameError at class load time in providers.
module SslInitializerHelper
module_function
# PEM certificate format markers (X.509 standard)
PEM_CERT_BEGIN = "-----BEGIN CERTIFICATE-----"
PEM_CERT_END = "-----END CERTIFICATE-----"
# Validates a CA certificate file.
# Supports single certs and multi-cert PEM bundles (CA chains).
#
# @param path [String] Path to the CA certificate file
# @return [Hash] Validation result with :path, :valid, and :error keys
def validate_ca_certificate_file(path)
result = { path: nil, valid: false, error: nil }
unless File.exist?(path)
result[:error] = "File not found: #{path}"
Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not found: #{path}")
return result
end
unless File.readable?(path)
result[:error] = "File not readable: #{path}"
Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not readable: #{path}")
return result
end
unless File.file?(path)
result[:error] = "Path is not a file: #{path}"
Rails.logger.warn("[SSL] SSL_CA_FILE specified but is not a file: #{path}")
return result
end
# Sanity check file size (CA certs should be < 1MB)
file_size = File.size(path)
if file_size > 1_000_000
result[:error] = "File too large (#{file_size} bytes) - expected a PEM certificate"
Rails.logger.warn("[SSL] SSL_CA_FILE is unexpectedly large: #{path} (#{file_size} bytes)")
return result
end
# Validate PEM format using standard X.509 markers
content = File.read(path)
unless content.include?(PEM_CERT_BEGIN)
result[:error] = "Invalid PEM format - missing BEGIN CERTIFICATE marker"
Rails.logger.warn("[SSL] SSL_CA_FILE does not appear to be a valid PEM certificate: #{path}")
return result
end
unless content.include?(PEM_CERT_END)
result[:error] = "Invalid PEM format - missing END CERTIFICATE marker"
Rails.logger.warn("[SSL] SSL_CA_FILE has incomplete PEM format: #{path}")
return result
end
# Parse and validate every certificate in the PEM file.
# OpenSSL::X509::Certificate.new only parses the first PEM block,
# so multi-cert bundles (CA chains) need per-block validation.
begin
pem_blocks = content.scan(/#{PEM_CERT_BEGIN}[\s\S]+?#{PEM_CERT_END}/)
raise OpenSSL::X509::CertificateError, "No certificates found in PEM file" if pem_blocks.empty?
pem_blocks.each_with_index do |pem, index|
OpenSSL::X509::Certificate.new(pem)
rescue OpenSSL::X509::CertificateError => e
raise OpenSSL::X509::CertificateError, "Certificate #{index + 1} of #{pem_blocks.size} is invalid: #{e.message}"
end
result[:path] = path
result[:valid] = true
rescue OpenSSL::X509::CertificateError => e
result[:error] = "Invalid certificate: #{e.message}"
Rails.logger.warn("[SSL] SSL_CA_FILE contains invalid certificate: #{e.message}")
end
result
end
# Finds the system CA certificate bundle path using OpenSSL's detection
#
# @return [String, nil] Path to system CA bundle or nil if not found
def find_system_ca_bundle
# First, check if SSL_CERT_FILE is already set (user may have their own bundle)
existing_cert_file = ENV["SSL_CERT_FILE"]
if existing_cert_file.present? && File.exist?(existing_cert_file) && File.readable?(existing_cert_file)
return existing_cert_file
end
# Use OpenSSL's built-in CA file detection
openssl_ca_file = OpenSSL::X509::DEFAULT_CERT_FILE
if openssl_ca_file.present? && File.exist?(openssl_ca_file) && File.readable?(openssl_ca_file)
return openssl_ca_file
end
# Use OpenSSL's default certificate directory as fallback
openssl_ca_dir = OpenSSL::X509::DEFAULT_CERT_DIR
if openssl_ca_dir.present? && Dir.exist?(openssl_ca_dir)
# Look for common bundle file names in the certificate directory
%w[ca-certificates.crt ca-bundle.crt cert.pem].each do |bundle_name|
bundle_path = File.join(openssl_ca_dir, bundle_name)
return bundle_path if File.exist?(bundle_path) && File.readable?(bundle_path)
end
end
nil
end
# Creates a combined CA bundle with system CAs and custom CA.
# Writes to a predictable path (tmp/ssl_ca_bundle.pem) for easy debugging
# and to avoid Tempfile GC lifecycle issues.
#
# @param custom_ca_path [String] Path to the custom CA certificate
# @param output_path [String] Where to write the combined bundle
# @return [String, nil] Path to the combined bundle, or nil on failure
def create_combined_ca_bundle(custom_ca_path, output_path: COMBINED_CA_BUNDLE_PATH)
system_ca = find_system_ca_bundle
unless system_ca
Rails.logger.warn("[SSL] Could not find system CA bundle - using custom CA only")
return nil
end
begin
system_content = File.read(system_ca)
custom_content = File.read(custom_ca_path)
# Ensure the parent directory exists
FileUtils.mkdir_p(File.dirname(output_path))
File.write(output_path, system_content + "\n# Custom CA Certificate\n" + custom_content)
Rails.logger.info("[SSL] Created combined CA bundle: #{output_path}")
Rails.logger.info("[SSL] - System CA source: #{system_ca}")
Rails.logger.info("[SSL] - Custom CA source: #{custom_ca_path}")
output_path.to_s
rescue StandardError => e
Rails.logger.error("[SSL] Failed to create combined CA bundle: #{e.message}")
nil
end
end
# Logs SSL configuration summary at startup
#
# @param ssl_config [ActiveSupport::OrderedOptions] SSL configuration
def log_ssl_configuration(ssl_config)
if ssl_config.debug
Rails.logger.info("[SSL] Debug mode enabled - verbose SSL logging active")
end
if ssl_config.ca_file.present?
if ssl_config.ca_file_valid
Rails.logger.info("[SSL] Custom CA certificate configured and validated: #{ssl_config.ca_file}")
else
Rails.logger.error("[SSL] Custom CA certificate configured but invalid: #{ssl_config.ca_file_error}")
end
end
unless ssl_config.verify
Rails.logger.warn("[SSL] " + "=" * 60)
Rails.logger.warn("[SSL] WARNING: SSL verification is DISABLED")
Rails.logger.warn("[SSL] This is insecure and should only be used for development/testing")
Rails.logger.warn("[SSL] Set SSL_VERIFY=true or remove the variable for production")
Rails.logger.warn("[SSL] " + "=" * 60)
end
if ssl_config.debug
Rails.logger.info("[SSL] Configuration summary:")
Rails.logger.info("[SSL] - SSL verification: #{ssl_config.verify ? 'ENABLED' : 'DISABLED'}")
Rails.logger.info("[SSL] - Custom CA file: #{ssl_config.ca_file || 'not configured'}")
Rails.logger.info("[SSL] - CA file valid: #{ssl_config.ca_file_valid}")
Rails.logger.info("[SSL] - Combined CA bundle: #{ssl_config.combined_ca_bundle || 'not created'}")
Rails.logger.info("[SSL] - SSL_CERT_FILE: #{ENV['SSL_CERT_FILE'] || 'not set'}")
end
end
end
# Configure SSL settings
Rails.application.configure do
config.x.ssl ||= ActiveSupport::OrderedOptions.new
truthy_values = %w[1 true yes on].freeze
falsy_values = %w[0 false no off].freeze
# Debug mode for verbose SSL logging
debug_env = ENV["SSL_DEBUG"].to_s.strip.downcase
config.x.ssl.debug = truthy_values.include?(debug_env)
# SSL verification (default: true)
verify_env = ENV["SSL_VERIFY"].to_s.strip.downcase
config.x.ssl.verify = !falsy_values.include?(verify_env)
# Custom CA certificate file for trusting self-signed certificates
ca_file = ENV["SSL_CA_FILE"].presence
config.x.ssl.ca_file = nil
config.x.ssl.ca_file_valid = false
if ca_file
ca_file_status = SslInitializerHelper.validate_ca_certificate_file(ca_file)
config.x.ssl.ca_file = ca_file_status[:path]
config.x.ssl.ca_file_valid = ca_file_status[:valid]
config.x.ssl.ca_file_error = ca_file_status[:error]
# Create combined CA bundle and set SSL_CERT_FILE for global SSL configuration.
#
# This sets ENV["SSL_CERT_FILE"] globally so that ALL Ruby SSL connections
# (including gems like openid_connect that bypass SslConfigurable) will trust
# both system CAs (for public services) and the custom CA (for self-signed services).
if ca_file_status[:valid]
combined_path = SslInitializerHelper.create_combined_ca_bundle(ca_file_status[:path])
if combined_path
config.x.ssl.combined_ca_bundle = combined_path
ENV["SSL_CERT_FILE"] = combined_path
Rails.logger.info("[SSL] Set SSL_CERT_FILE=#{combined_path} for global SSL configuration")
else
# Fallback: just use the custom CA (may break connections to public services)
Rails.logger.warn("[SSL] Could not create combined CA bundle, using custom CA only. " \
"Connections to public services (not using your custom CA) may fail.")
ENV["SSL_CERT_FILE"] = ca_file_status[:path]
end
end
end
# Log configuration summary at startup
SslInitializerHelper.log_ssl_configuration(config.x.ssl)
end

View File

@@ -77,7 +77,14 @@ Rails.application.config.middleware.use OmniAuth::Builder do
client_options: { client_options: {
identifier: client_id, identifier: client_id,
secret: client_secret, secret: client_secret,
redirect_uri: redirect_uri redirect_uri: redirect_uri,
ssl: begin
ssl_config = Rails.configuration.x.ssl
ssl_opts = {}
ssl_opts[:ca_file] = ssl_config.ca_file if ssl_config&.ca_file.present?
ssl_opts[:verify] = false if ssl_config&.verify == false
ssl_opts
end
} }
} }

View File

@@ -471,7 +471,68 @@ When adding an OIDC provider, Sure validates the `.well-known/openid-configurati
- Ensure the issuer URL is correct and accessible - Ensure the issuer URL is correct and accessible
- Check firewall rules allow outbound HTTPS to the issuer - Check firewall rules allow outbound HTTPS to the issuer
- Verify the issuer returns valid JSON with an `issuer` field - Verify the issuer returns valid JSON with an `issuer` field
- For self-signed certificates, you may need to configure SSL verification - For self-signed certificates, configure SSL verification (see below)
### Self-signed certificate support
If your identity provider uses self-signed certificates or certificates from an internal CA, configure the following environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `SSL_CA_FILE` | Path to custom CA certificate (PEM format) | Not set |
| `SSL_VERIFY` | Enable/disable SSL verification | `true` |
| `SSL_DEBUG` | Enable verbose SSL logging | `false` |
**Option 1: Custom CA certificate (recommended)**
Mount your CA certificate into the container and set `SSL_CA_FILE`:
```yaml
# docker-compose.yml
services:
app:
environment:
SSL_CA_FILE: /certs/my-ca.crt
volumes:
- ./my-ca.crt:/certs/my-ca.crt:ro
```
The certificate file must:
- Be in PEM format (starts with `-----BEGIN CERTIFICATE-----`)
- Be readable by the application
- Be the CA certificate that signed your server's SSL certificate
**Option 2: Disable SSL verification (NOT recommended for production)**
For testing only, you can disable SSL verification:
```bash
SSL_VERIFY=false
```
**Warning:** Disabling SSL verification removes protection against man-in-the-middle attacks. Only use this for development or testing environments.
**Troubleshooting SSL issues**
Enable debug logging to diagnose SSL certificate problems:
```bash
SSL_DEBUG=true
```
This will log detailed information about SSL connections, including:
- Which CA file is being used
- SSL verification mode
- Detailed error messages with resolution hints
Common error messages and solutions:
| Error | Solution |
|-------|----------|
| `self-signed certificate` | Set `SSL_CA_FILE` to your CA certificate |
| `certificate verify failed` | Ensure `SSL_CA_FILE` points to the correct CA |
| `certificate has expired` | Renew the server's SSL certificate |
| `unknown CA` | Add the issuing CA to `SSL_CA_FILE` |
### Rate limiting errors (429) ### Rate limiting errors (429)

View File

@@ -0,0 +1,165 @@
require "test_helper"
class SslConfigurableTest < ActiveSupport::TestCase
# Create a simple test host that extends SslConfigurable, mirroring how
# providers use it in the actual codebase.
class SslTestHost
extend SslConfigurable
end
setup do
# Snapshot original config so we can restore it in teardown
@original_verify = Rails.configuration.x.ssl.verify
@original_ca_file = Rails.configuration.x.ssl.ca_file
@original_debug = Rails.configuration.x.ssl.debug
end
teardown do
Rails.configuration.x.ssl.verify = @original_verify
Rails.configuration.x.ssl.ca_file = @original_ca_file
Rails.configuration.x.ssl.debug = @original_debug
end
# -- ssl_verify? --
test "ssl_verify? returns true when verify is nil (default)" do
Rails.configuration.x.ssl.verify = nil
assert SslTestHost.ssl_verify?
end
test "ssl_verify? returns true when verify is true" do
Rails.configuration.x.ssl.verify = true
assert SslTestHost.ssl_verify?
end
test "ssl_verify? returns false when verify is explicitly false" do
Rails.configuration.x.ssl.verify = false
refute SslTestHost.ssl_verify?
end
# -- ssl_ca_file --
test "ssl_ca_file returns nil when no CA file is configured" do
Rails.configuration.x.ssl.ca_file = nil
assert_nil SslTestHost.ssl_ca_file
end
test "ssl_ca_file returns the configured path" do
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
assert_equal "/certs/my-ca.crt", SslTestHost.ssl_ca_file
end
# -- ssl_debug? --
test "ssl_debug? returns false when debug is nil" do
Rails.configuration.x.ssl.debug = nil
refute SslTestHost.ssl_debug?
end
test "ssl_debug? returns true when debug is true" do
Rails.configuration.x.ssl.debug = true
assert SslTestHost.ssl_debug?
end
# -- faraday_ssl_options --
test "faraday_ssl_options returns verify true with no CA file by default" do
Rails.configuration.x.ssl.verify = true
Rails.configuration.x.ssl.ca_file = nil
Rails.configuration.x.ssl.debug = false
options = SslTestHost.faraday_ssl_options
assert_equal true, options[:verify]
assert_nil options[:ca_file]
end
test "faraday_ssl_options includes ca_file when configured" do
Rails.configuration.x.ssl.verify = true
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
Rails.configuration.x.ssl.debug = false
options = SslTestHost.faraday_ssl_options
assert_equal true, options[:verify]
assert_equal "/certs/my-ca.crt", options[:ca_file]
end
test "faraday_ssl_options returns verify false when verification disabled" do
Rails.configuration.x.ssl.verify = false
Rails.configuration.x.ssl.ca_file = nil
Rails.configuration.x.ssl.debug = false
options = SslTestHost.faraday_ssl_options
assert_equal false, options[:verify]
end
test "faraday_ssl_options includes both verify false and ca_file when both configured" do
Rails.configuration.x.ssl.verify = false
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
Rails.configuration.x.ssl.debug = false
options = SslTestHost.faraday_ssl_options
assert_equal false, options[:verify]
assert_equal "/certs/my-ca.crt", options[:ca_file]
end
# -- httparty_ssl_options --
test "httparty_ssl_options returns verify true with no CA file by default" do
Rails.configuration.x.ssl.verify = true
Rails.configuration.x.ssl.ca_file = nil
Rails.configuration.x.ssl.debug = false
options = SslTestHost.httparty_ssl_options
assert_equal true, options[:verify]
assert_nil options[:ssl_ca_file]
end
test "httparty_ssl_options includes ssl_ca_file when configured" do
Rails.configuration.x.ssl.verify = true
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
Rails.configuration.x.ssl.debug = false
options = SslTestHost.httparty_ssl_options
assert_equal true, options[:verify]
assert_equal "/certs/my-ca.crt", options[:ssl_ca_file]
end
test "httparty_ssl_options returns verify false when verification disabled" do
Rails.configuration.x.ssl.verify = false
Rails.configuration.x.ssl.ca_file = nil
Rails.configuration.x.ssl.debug = false
options = SslTestHost.httparty_ssl_options
assert_equal false, options[:verify]
end
# -- net_http_verify_mode --
test "net_http_verify_mode returns VERIFY_PEER when verification enabled" do
Rails.configuration.x.ssl.verify = true
Rails.configuration.x.ssl.debug = false
assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode
end
test "net_http_verify_mode returns VERIFY_NONE when verification disabled" do
Rails.configuration.x.ssl.verify = false
Rails.configuration.x.ssl.debug = false
assert_equal OpenSSL::SSL::VERIFY_NONE, SslTestHost.net_http_verify_mode
end
test "net_http_verify_mode returns VERIFY_PEER when verify is nil" do
Rails.configuration.x.ssl.verify = nil
Rails.configuration.x.ssl.debug = false
assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode
end
end

View File

@@ -0,0 +1,66 @@
require "test_helper"
require "ostruct"
class Eval::LangfuseClientTest < ActiveSupport::TestCase
# -- CRL error list --
test "crl_errors includes standard CRL error codes" do
errors = Eval::Langfuse::Client.crl_errors
assert_includes errors, OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL
assert_includes errors, OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED
assert_includes errors, OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
end
test "crl_errors is frozen" do
assert Eval::Langfuse::Client.crl_errors.frozen?
end
# -- CRL verify callback behavior --
# The callback should bypass only CRL-specific errors while preserving the
# original verification result for all other error types.
test "CRL callback returns true for CRL-unavailable errors" do
crl_error_codes = Eval::Langfuse::Client.crl_errors
store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL)
callback = build_crl_callback(crl_error_codes)
assert callback.call(false, store_ctx), "CRL errors should be bypassed even when preverify_ok is false"
end
test "CRL callback preserves preverify_ok for non-CRL errors" do
crl_error_codes = Eval::Langfuse::Client.crl_errors
# V_OK (0) is not a CRL error
store_ctx = OpenStruct.new(error: 0)
callback = build_crl_callback(crl_error_codes)
assert callback.call(true, store_ctx), "Non-CRL errors with preverify_ok=true should pass"
refute callback.call(false, store_ctx), "Non-CRL errors with preverify_ok=false should fail"
end
test "CRL callback rejects cert errors that are not CRL-related" do
crl_error_codes = Eval::Langfuse::Client.crl_errors
# V_ERR_CERT_HAS_EXPIRED is a real cert error, not CRL
store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED)
callback = build_crl_callback(crl_error_codes)
refute callback.call(false, store_ctx), "Non-CRL cert errors should not be bypassed"
end
private
# Reconstructs the same lambda used in Eval::Langfuse::Client#execute_request
# for isolated testing without needing a real Net::HTTP connection.
def build_crl_callback(crl_error_codes)
->(preverify_ok, store_ctx) {
if crl_error_codes.include?(store_ctx.error)
true
else
preverify_ok
end
}
end
end

View File

@@ -10,7 +10,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
# First call raises timeout, second call succeeds # First call raises timeout, second call succeeds
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
HTTParty.expects(:get) Provider::Simplefin.expects(:get)
.times(2) .times(2)
.raises(Net::ReadTimeout.new("Connection timed out")) .raises(Net::ReadTimeout.new("Connection timed out"))
.then.returns(mock_response) .then.returns(mock_response)
@@ -25,7 +25,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
test "retries on Net::OpenTimeout and succeeds on retry" do test "retries on Net::OpenTimeout and succeeds on retry" do
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
HTTParty.expects(:get) Provider::Simplefin.expects(:get)
.times(2) .times(2)
.raises(Net::OpenTimeout.new("Connection timed out")) .raises(Net::OpenTimeout.new("Connection timed out"))
.then.returns(mock_response) .then.returns(mock_response)
@@ -39,7 +39,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
test "retries on SocketError and succeeds on retry" do test "retries on SocketError and succeeds on retry" do
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}') mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
HTTParty.expects(:get) Provider::Simplefin.expects(:get)
.times(2) .times(2)
.raises(SocketError.new("Failed to open TCP connection")) .raises(SocketError.new("Failed to open TCP connection"))
.then.returns(mock_response) .then.returns(mock_response)
@@ -51,7 +51,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
end end
test "raises SimplefinError after max retries exceeded" do test "raises SimplefinError after max retries exceeded" do
HTTParty.expects(:get) Provider::Simplefin.expects(:get)
.times(4) # Initial + 3 retries .times(4) # Initial + 3 retries
.raises(Net::ReadTimeout.new("Connection timed out")) .raises(Net::ReadTimeout.new("Connection timed out"))
@@ -66,7 +66,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
end end
test "does not retry on non-retryable errors" do test "does not retry on non-retryable errors" do
HTTParty.expects(:get) Provider::Simplefin.expects(:get)
.times(1) .times(1)
.raises(ArgumentError.new("Invalid argument")) .raises(ArgumentError.new("Invalid argument"))
@@ -80,7 +80,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
test "handles HTTP 429 rate limit response" do test "handles HTTP 429 rate limit response" do
mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded") mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded")
HTTParty.expects(:get).returns(mock_response) Provider::Simplefin.expects(:get).returns(mock_response)
error = assert_raises(Provider::Simplefin::SimplefinError) do error = assert_raises(Provider::Simplefin::SimplefinError) do
@provider.get_accounts(@access_url) @provider.get_accounts(@access_url)
@@ -93,7 +93,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
test "handles HTTP 500 server error response" do test "handles HTTP 500 server error response" do
mock_response = OpenStruct.new(code: 500, body: "Internal Server Error") mock_response = OpenStruct.new(code: 500, body: "Internal Server Error")
HTTParty.expects(:get).returns(mock_response) Provider::Simplefin.expects(:get).returns(mock_response)
error = assert_raises(Provider::Simplefin::SimplefinError) do error = assert_raises(Provider::Simplefin::SimplefinError) do
@provider.get_accounts(@access_url) @provider.get_accounts(@access_url)
@@ -106,7 +106,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
setup_token = Base64.encode64("https://example.com/claim") setup_token = Base64.encode64("https://example.com/claim")
mock_response = OpenStruct.new(code: 200, body: "https://example.com/access") mock_response = OpenStruct.new(code: 200, body: "https://example.com/access")
HTTParty.expects(:post) Provider::Simplefin.expects(:post)
.times(2) .times(2)
.raises(Net::ReadTimeout.new("Connection timed out")) .raises(Net::ReadTimeout.new("Connection timed out"))
.then.returns(mock_response) .then.returns(mock_response)