diff --git a/Readme.rdoc b/Readme.rdoc index f2b3912..d470577 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -29,6 +29,52 @@ The primary class you will use to track events is Mixpanel::Tracker. An instance Mixpanel::Tracker is enough to send events directly to \Mixpanel, and get you integrated right away. +== Service Account Authentication + +Service accounts provide secure server-to-server authentication and are recommended over +API keys for import operations and feature flags. + +=== Import with Service Account + + require 'mixpanel-ruby' + + # Create service account credentials + credentials = Mixpanel::ServiceAccountCredentials.new( + 'your-service-account-username', + 'your-service-account-secret', + 'your-project-id' + ) + + tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + + # Import historical events using service account + tracker.import(credentials, 'User1', 'Past Event', { + 'time' => 1369353600, + 'Source' => 'Import' + }) + +=== Feature Flags with Service Account + + require 'mixpanel-ruby' + + credentials = Mixpanel::ServiceAccountCredentials.new( + 'your-service-account-username', + 'your-service-account-secret', + 'your-project-id' + ) + + # Pass credentials directly to the tracker + tracker = Mixpanel::Tracker.new( + YOUR_MIXPANEL_TOKEN, + nil, + credentials: credentials, + local_flags_config: {}, + remote_flags_config: {} + ) + + # Use feature flags + is_enabled = tracker.remote_flags.is_enabled?('my-flag', { 'distinct_id' => 'User1' }) + == Additional Information For more information please visit: diff --git a/lib/mixpanel-ruby.rb b/lib/mixpanel-ruby.rb index 79df69c..4f27b51 100644 --- a/lib/mixpanel-ruby.rb +++ b/lib/mixpanel-ruby.rb @@ -1,6 +1,7 @@ require 'mixpanel-ruby/consumer.rb' require 'mixpanel-ruby/tracker.rb' require 'mixpanel-ruby/version.rb' +require 'mixpanel-ruby/credentials.rb' require 'mixpanel-ruby/flags/utils.rb' require 'mixpanel-ruby/flags/types.rb' require 'mixpanel-ruby/flags/flags_provider.rb' diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 3078f66..abcfd8b 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -85,13 +85,18 @@ def send!(type, message) decoded_message = JSON.load(message) api_key = decoded_message["api_key"] + credentials = decoded_message["credentials"] data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '') form_data = {"data" => data, "verbose" => 1} - form_data.merge!("api_key" => api_key) if api_key + + # Only add api_key to form data if using legacy API key (not service account credentials) + if api_key && !credentials + form_data.merge!("api_key" => api_key) + end begin - response_code, response_body = request(endpoint, form_data) + response_code, response_body = request(endpoint, form_data, credentials, type) rescue => e raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".") end @@ -123,11 +128,24 @@ def send(type, message) # # as the result of the response. Response code should be nil if # the request never receives a response for some reason. - def request(endpoint, form_data) + def request(endpoint, form_data, credentials = nil, type = nil) uri = URI(endpoint) + + # Add project_id as query parameter for import endpoint with service account credentials + if credentials && type == :import + query_params = URI.decode_www_form(uri.query || '').to_h + query_params['project_id'] = credentials['project_id'] + uri.query = URI.encode_www_form(query_params) + end + request = Net::HTTP::Post.new(uri.request_uri) request.set_form_data(form_data) + # Use Basic Auth with service account credentials for import endpoint + if credentials && type == :import + request.basic_auth(credentials['username'], credentials['secret']) + end + client = Net::HTTP.new(uri.host, uri.port) client.use_ssl = true client.open_timeout = 10 diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb new file mode 100644 index 0000000..8c07650 --- /dev/null +++ b/lib/mixpanel-ruby/credentials.rb @@ -0,0 +1,40 @@ +module Mixpanel + # Service account credentials for server-to-server authentication + # This is the recommended authentication method over API keys + class ServiceAccountCredentials + attr_reader :username, :secret, :project_id + + # Create service account credentials + # @param username [String] Service account username + # @param secret [String] Service account secret + # @param project_id [String, Integer] Mixpanel project ID (accepts string or integer) + def initialize(username, secret, project_id) + raise ArgumentError, 'username is required' if username.nil? || username.empty? + raise ArgumentError, 'secret is required' if secret.nil? || secret.empty? + raise ArgumentError, 'project_id is required' if project_id.nil? + + # Convert project_id to string if it's an integer (Mixpanel dashboard shows numeric IDs) + project_id = project_id.to_s if project_id.is_a?(Integer) + raise ArgumentError, 'project_id is required' if project_id.empty? + + @username = username + @secret = secret + @project_id = project_id + end + + # JSON serialization support - called automatically by JSON.generate/to_json + # Note: secret IS included because it's needed by the Consumer for HTTP Basic Auth + def as_json(options = nil) + { + 'username' => @username, + 'secret' => @secret, + 'project_id' => @project_id + } + end + + # Explicit to_json method for direct .to_json calls + def to_json(*args) + as_json.to_json(*args) + end + end +end diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index be653cd..8c0107c 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -89,17 +89,17 @@ def track(distinct_id, event, properties={}, ip=nil) # # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # - # # Track that user "12345"'s credit card was declined + # # Using deprecated API key (still supported) # tracker.import("API_KEY", "12345", "Credit Card Declined") # - # # Properties describe the circumstances of the event, - # # or aspects of the source or user associated with the event - # tracker.import("API_KEY", "12345", "Welcome Email Sent", { + # # Using service account credentials (recommended) + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker.import(credentials, "12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1369353600, # }) - def import(api_key, distinct_id, event, properties={}, ip=nil) + def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil) properties = { 'distinct_id' => distinct_id, 'token' => @token, @@ -116,9 +116,16 @@ def import(api_key, distinct_id, event, properties={}, ip=nil) message = { 'data' => data, - 'api_key' => api_key, } + # Support both service account credentials and legacy API key + if api_key_or_credentials.is_a?(ServiceAccountCredentials) + message['credentials'] = api_key_or_credentials + else + warn '[DEPRECATION] Using API key for import is deprecated. Please use ServiceAccountCredentials instead. See https://developer.mixpanel.com/reference/service-accounts for more information.' + message['api_key'] = api_key_or_credentials + end + ret = true begin @sink.call(:import, message.to_json) diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 116f011..eb8a715 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -12,7 +12,7 @@ module Flags # Base class for feature flags providers # Provides common HTTP handling and exposure event tracking class FlagsProvider - # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds + # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds, :credentials (optional) # @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions') # @param tracker_callback [Proc] Function used to track events (bound tracker.track method) # @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote' @@ -23,6 +23,7 @@ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, err @tracker_callback = tracker_callback @evaluation_mode = evaluation_mode @error_handler = error_handler + @credentials = provider_config[:credentials] end # Make HTTP request to flags API endpoint @@ -31,12 +32,19 @@ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, err # @raise [Mixpanel::ConnectionError] on network errors # @raise [Mixpanel::ServerError] on HTTP errors def call_flags_endpoint(additional_params = nil) + # Always use token in query params common_params = Utils.prepare_common_query_params( @provider_config[:token], Mixpanel::VERSION ) params = common_params.merge(additional_params || {}) + + # Add project_id as query parameter when using service account credentials + if @credentials + params['project_id'] = @credentials.project_id + end + query_string = URI.encode_www_form(params) uri = URI::HTTPS.build( @@ -53,7 +61,12 @@ def call_flags_endpoint(additional_params = nil) request = Net::HTTP::Get.new(uri.request_uri) - request.basic_auth(@provider_config[:token], '') + # Use service account credentials for basic auth if provided, otherwise use token + if @credentials + request.basic_auth(@credentials.username, @credentials.secret) + else + request.basic_auth(@provider_config[:token], '') + end request['Content-Type'] = 'application/json' request['traceparent'] = Utils.generate_traceparent diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index a434053..5ea98fa 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -18,7 +18,8 @@ class LocalFlagsProvider < FlagsProvider # @param config [Hash] Local flags configuration # @param tracker_callback [Proc] Callback to track events # @param error_handler [Mixpanel::ErrorHandler] Error handler - def initialize(token, config, tracker_callback, error_handler) + # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials + def initialize(token, config, tracker_callback, error_handler, credentials = nil) # compact: an explicit nil from the caller (e.g. # polling_interval_in_seconds: nil) must not override a sane default. # Both the previous sleep(nil) and the current @@ -35,7 +36,8 @@ def initialize(token, config, tracker_callback, error_handler) provider_config = { token: token, api_host: @config[:api_host], - request_timeout_in_seconds: @config[:request_timeout_in_seconds] + request_timeout_in_seconds: @config[:request_timeout_in_seconds], + credentials: credentials } super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler) diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index eaa6471..7ced48f 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -12,15 +12,17 @@ class RemoteFlagsProvider < FlagsProvider # @param token [String] Mixpanel project token # @param config [Hash] Remote flags configuration + # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials # @param tracker_callback [Proc] Callback to track events # @param error_handler [Mixpanel::ErrorHandler] Error handler - def initialize(token, config, tracker_callback, error_handler) + def initialize(token, config, credentials, tracker_callback, error_handler) merged_config = DEFAULT_CONFIG.merge(config || {}) provider_config = { token: token, api_host: merged_config[:api_host], - request_timeout_in_seconds: merged_config[:request_timeout_in_seconds] + request_timeout_in_seconds: merged_config[:request_timeout_in_seconds], + credentials: credentials } super(provider_config, '/flags', tracker_callback, 'remote', error_handler) diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 667c672..84e80e0 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -62,7 +62,12 @@ class Tracker < Events # If a block is provided, it is passed a type (one of :event or :profile_update) # and a string message. This same format is accepted by Mixpanel::Consumer#send! # and Mixpanel::BufferedConsumer#send! - def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_config: nil, &block) + # + # Optional parameters: + # - credentials: ServiceAccountCredentials for feature flags authentication + # - local_flags_config: Configuration hash for local feature flags + # - remote_flags_config: Configuration hash for remote feature flags + def initialize(token, error_handler=nil, credentials: nil, local_flags_config: nil, remote_flags_config: nil, &block) super(token, error_handler, &block) @token = token @people = People.new(token, error_handler, &block) @@ -74,7 +79,8 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c token, local_flags_config, method(:track), # Pass bound method as callback - error_handler || ErrorHandler.new + error_handler || ErrorHandler.new, + credentials ) end @@ -83,6 +89,7 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c @remote_flags = Flags::RemoteFlagsProvider.new( token, remote_flags_config, + credentials, method(:track), # Pass bound method as callback error_handler || ErrorHandler.new ) @@ -122,19 +129,19 @@ def track(distinct_id, event, properties={}, ip=nil) # # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # - # # Import event that user "12345"'s credit card was declined + # # Using deprecated API key (still supported) # tracker.import("API_KEY", "12345", "Credit Card Declined", { # 'time' => 1310111365 # }) # - # # Properties describe the circumstances of the event, - # # or aspects of the source or user associated with the event - # tracker.import("API_KEY", "12345", "Welcome Email Sent", { + # # Using service account credentials (recommended) + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker.import(credentials, "12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1310111365 # }) - def import(api_key, distinct_id, event, properties={}, ip=nil) + def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil) # This is here strictly to allow rdoc to include the relevant # documentation super diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 941f256..0b6f4b7 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -36,6 +36,30 @@ with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'api_key' => 'API_KEY', 'verbose' => '1' }) end + it 'should send a request to api.mixpanel.com/import with service account credentials' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project-123').to_return({:body => '{"status": 1, "error": null}'}) + credentials = { + 'username' => 'test-user', + 'secret' => 'test-secret', + 'project_id' => 'test-project-123' + } + subject.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'credentials' => credentials}.to_json) + + # Should use Basic Auth header with username:secret + # Should add project_id as query parameter + # Should NOT include credentials in POST body + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project-123'). + with( + :body => { + 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', + 'verbose' => '1' + }, + :headers => { + 'Authorization' => 'Basic ' + Base64.strict_encode64('test-user:test-secret') + } + ) + end + it 'should encode long messages without newlines' do stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'}) subject.send!(:event, {'data' => 'BASE64-ENCODED VERSION OF BIN. THIS METHOD COMPLIES WITH RFC 2045. LINE FEEDS ARE ADDED TO EVERY 60 ENCODED CHARACTORS. IN RUBY 1.8 WE NEED TO JUST CALL ENCODE64 AND REMOVE THE LINE FEEDS, IN RUBY 1.9 WE CALL STRIC_ENCODED64 METHOD INSTEAD'}.to_json) diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb new file mode 100644 index 0000000..38b427a --- /dev/null +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -0,0 +1,81 @@ +require 'mixpanel-ruby' + +describe Mixpanel::ServiceAccountCredentials do + describe '#initialize' do + it 'creates credentials with valid parameters' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + expect(credentials.username).to eq('user') + expect(credentials.secret).to eq('secret') + expect(credentials.project_id).to eq('project123') + end + + it 'raises ArgumentError when username is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new(nil, 'secret', 'project123') + }.to raise_error(ArgumentError, 'username is required') + end + + it 'raises ArgumentError when username is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('', 'secret', 'project123') + }.to raise_error(ArgumentError, 'username is required') + end + + it 'raises ArgumentError when secret is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', nil, 'project123') + }.to raise_error(ArgumentError, 'secret is required') + end + + it 'raises ArgumentError when secret is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', '', 'project123') + }.to raise_error(ArgumentError, 'secret is required') + end + + it 'raises ArgumentError when project_id is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', 'secret', nil) + }.to raise_error(ArgumentError, 'project_id is required') + end + + it 'raises ArgumentError when project_id is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', 'secret', '') + }.to raise_error(ArgumentError, 'project_id is required') + end + + it 'accepts integer project_id and converts to string' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 12345) + expect(credentials.project_id).to eq('12345') + end + end + + describe 'JSON serialization' do + it 'serializes to JSON correctly' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + json_str = credentials.to_json + parsed = JSON.parse(json_str) + # Secret IS included so Consumer can use it for HTTP Basic Auth + expect(parsed).to eq({ + 'username' => 'user', + 'secret' => 'secret', + 'project_id' => 'project123' + }) + # Secret is also accessible on the object + expect(credentials.secret).to eq('secret') + end + + it 'survives JSON round-trip' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + message = {'credentials' => credentials}.to_json + decoded = JSON.load(message) + # Secret IS included so Consumer can use it for HTTP Basic Auth + expect(decoded['credentials']).to eq({ + 'username' => 'user', + 'secret' => 'secret', + 'project_id' => 'project123' + }) + end + end +end diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 57f6ade..72d2cdc 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -3,6 +3,7 @@ require 'mixpanel-ruby/events.rb' require 'mixpanel-ruby/version.rb' +require 'mixpanel-ruby/credentials.rb' describe Mixpanel::Events do before(:each) do @@ -33,9 +34,11 @@ end it 'should send a well formed import/ message' do - @events.import('API_KEY', 'TEST ID', 'Test Event', { - 'Circumstances' => 'During a test' - }) + expect { + @events.import('API_KEY', 'TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + }.to output(/DEPRECATION.*API key for import is deprecated/).to_stderr expect(@log).to eq([[:import, { 'api_key' => 'API_KEY', 'data' => { @@ -73,4 +76,34 @@ } } ]]) end + + it 'should send a well formed import/ message with service account credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + @events.import(credentials, 'TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + + expect(@log.length).to eq(1) + expect(@log[0][0]).to eq(:import) + + message = @log[0][1] + # Secret IS included in serialization so Consumer can use it for HTTP Basic Auth + expect(message['credentials']).to eq({ + 'username' => 'test-user', + 'secret' => 'test-secret', + 'project_id' => 'test-project-123' + }) + expect(message['api_key']).to be_nil + expect(message['data']).to eq({ + 'event' => 'Test Event', + 'properties' => { + 'Circumstances' => 'During a test', + 'distinct_id' => 'TEST ID', + 'mp_lib' => 'ruby', + '$lib_version' => Mixpanel::VERSION, + 'token' => 'TEST TOKEN', + 'time' => @time_now.to_i * 1000 + } + }) + end end diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 1302f88..fad90d0 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -2,6 +2,7 @@ require 'timeout' require 'mixpanel-ruby/flags/local_flags_provider' require 'mixpanel-ruby/flags/types' +require 'mixpanel-ruby/credentials' require 'webmock/rspec' describe Mixpanel::Flags::LocalFlagsProvider do @@ -17,7 +18,8 @@ test_token, config, mock_tracker, - mock_error_handler + mock_error_handler, + nil # credentials ) end @@ -770,7 +772,8 @@ def user_context_with_properties(properties) test_token, { enable_polling: true, polling_interval_in_seconds: 0.1 }, mock_tracker, - mock_error_handler + mock_error_handler, + nil # credentials ) begin @@ -929,4 +932,35 @@ def user_context_with_properties(properties) end end end + + describe 'service account credentials' do + it 'uses service account credentials for authentication' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + flag = create_test_flag + + stub_request(:get, endpoint_url_regex) + .with( + basic_auth: ['test-user', 'test-secret'] + ) + .to_return( + status: 200, + body: { code: 200, flags: [flag] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + config, + mock_tracker, + mock_error_handler, + credentials + ) + + credentials_provider.start_polling_for_definitions! + result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + + expect(result).not_to eq('fallback') + credentials_provider.stop_polling_for_definitions! + end + end end diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index 8e7308b..f7f959c 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -15,6 +15,7 @@ Mixpanel::Flags::RemoteFlagsProvider.new( test_token, config, + nil, # credentials mock_tracker, mock_error_handler ) @@ -438,4 +439,38 @@ def stub_flags_request_error(error) provider.send(:track_exposure_event, 'test_flag', variant, test_context) end end + + describe 'service account credentials' do + it 'uses service account credentials for authentication' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'treatment' + } + }) + + stub_request(:get, endpoint_url_regex) + .with( + basic_auth: ['test-user', 'test-secret'] + ) + .to_return( + status: 200, + body: response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + credentials_provider = Mixpanel::Flags::RemoteFlagsProvider.new( + test_token, + config, + credentials, + mock_tracker, + mock_error_handler + ) + + result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + expect(result).to eq('treatment') + end + end end diff --git a/spec/mixpanel-ruby/tracker_spec.rb b/spec/mixpanel-ruby/tracker_spec.rb index 1fe54eb..1aa3a8a 100644 --- a/spec/mixpanel-ruby/tracker_spec.rb +++ b/spec/mixpanel-ruby/tracker_spec.rb @@ -131,4 +131,22 @@ expect(expect).to eq(found) end end + + describe 'service account credentials' do + it 'should pass credentials to flags providers when passed directly' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + + tracker = Mixpanel::Tracker.new( + 'TEST TOKEN', + nil, + credentials: credentials, + local_flags_config: {}, + remote_flags_config: {} + ) + + # Verify credentials were passed to providers by checking internal state + expect(tracker.local_flags.instance_variable_get(:@credentials)).to eq(credentials) + expect(tracker.remote_flags.instance_variable_get(:@credentials)).to eq(credentials) + end + end end