mixpanel-ruby 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +14 -0
  3. data/.github/modules.json +18 -0
  4. data/.github/scripts/generate-changelog.sh +87 -0
  5. data/.github/workflows/pr-title-check.yml +51 -0
  6. data/.github/workflows/prepare-release.yml +189 -0
  7. data/.github/workflows/release-rubygems.yml +217 -0
  8. data/.github/workflows/ruby.yml +31 -2
  9. data/CHANGELOG.md +30 -0
  10. data/Readme.rdoc +65 -0
  11. data/lib/mixpanel-ruby/consumer.rb +54 -6
  12. data/lib/mixpanel-ruby/credentials.rb +26 -0
  13. data/lib/mixpanel-ruby/events.rb +69 -11
  14. data/lib/mixpanel-ruby/flags/flags_provider.rb +28 -10
  15. data/lib/mixpanel-ruby/flags/local_flags_provider.rb +104 -40
  16. data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +4 -2
  17. data/lib/mixpanel-ruby/tracker.rb +39 -9
  18. data/lib/mixpanel-ruby/version.rb +1 -1
  19. data/lib/mixpanel-ruby.rb +1 -0
  20. data/openfeature-provider/CHANGELOG.md +5 -0
  21. data/openfeature-provider/Gemfile +7 -0
  22. data/openfeature-provider/README.md +288 -0
  23. data/openfeature-provider/RELEASE.md +52 -0
  24. data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +170 -0
  25. data/openfeature-provider/lib/mixpanel/openfeature/version.rb +7 -0
  26. data/openfeature-provider/lib/mixpanel/openfeature.rb +4 -0
  27. data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +25 -0
  28. data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +606 -0
  29. data/openfeature-provider/spec/spec_helper.rb +23 -0
  30. data/spec/mixpanel-ruby/consumer_spec.rb +81 -0
  31. data/spec/mixpanel-ruby/credentials_security_spec.rb +108 -0
  32. data/spec/mixpanel-ruby/credentials_spec.rb +54 -0
  33. data/spec/mixpanel-ruby/events_spec.rb +152 -0
  34. data/spec/mixpanel-ruby/flags/local_flags_spec.rb +209 -2
  35. data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +36 -0
  36. data/spec/mixpanel-ruby/tracker_spec.rb +18 -0
  37. metadata +23 -3
@@ -3,6 +3,7 @@ require 'spec_helper'
3
3
  require 'webmock'
4
4
 
5
5
  require 'mixpanel-ruby/consumer'
6
+ require 'mixpanel-ruby/credentials'
6
7
 
7
8
  describe Mixpanel::Consumer do
8
9
  before { WebMock.reset! }
@@ -95,6 +96,56 @@ describe Mixpanel::Consumer do
95
96
  it_behaves_like 'consumer'
96
97
  end
97
98
 
99
+ context 'service account credentials' do
100
+ it 'should send a request to api.mixpanel.com/import with service account credentials' do
101
+ stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project-123').to_return({:body => '{"status": 1, "error": null}'})
102
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123')
103
+ consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials)
104
+
105
+ consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE'}.to_json)
106
+
107
+ # Should use Basic Auth header with username:secret
108
+ # Should add project_id as query parameter
109
+ # Should NOT include credentials in POST body or message
110
+ expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project-123').
111
+ with(
112
+ :body => {
113
+ 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
114
+ 'verbose' => '1'
115
+ },
116
+ :headers => {
117
+ 'Authorization' => 'Basic ' + Base64.strict_encode64('test-user:test-secret')
118
+ }
119
+ )
120
+ end
121
+
122
+ it 'should raise ArgumentError when credentials is not ServiceAccountCredentials' do
123
+ consumer = Mixpanel::Consumer.new
124
+
125
+ expect {
126
+ consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: 'invalid', type: :import)
127
+ }.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials, got String/)
128
+ end
129
+
130
+ it 'should not include api_key when credentials are present' do
131
+ stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project').to_return({:body => '{"status": 1, "error": null}'})
132
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'test-project')
133
+ consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials)
134
+
135
+ # Message includes api_key but it should be ignored when credentials are present
136
+ consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'SHOULD_BE_IGNORED'}.to_json)
137
+
138
+ # api_key should NOT be in the request body (only data and verbose)
139
+ expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project').
140
+ with(
141
+ :body => {
142
+ 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
143
+ 'verbose' => '1'
144
+ }
145
+ )
146
+ end
147
+ end
148
+
98
149
  end
99
150
 
100
151
  describe Mixpanel::BufferedConsumer do
@@ -205,4 +256,34 @@ describe Mixpanel::BufferedConsumer do
205
256
  end
206
257
  end
207
258
 
259
+ context 'with service account credentials' do
260
+ it 'should pass credentials to consumer when using BufferedConsumer' do
261
+ stub_request(:any, 'https://api.mixpanel.com/import?project_id=buffered-project').to_return({:body => '{"status": 1, "error": null}'})
262
+ credentials = Mixpanel::ServiceAccountCredentials.new('buffered-user', 'buffered-secret', 'buffered-project')
263
+ consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 2, credentials: credentials)
264
+
265
+ # Import messages are not buffered - they're sent immediately
266
+ consumer.send!(:import, {'data' => 'EVENT 1'}.to_json)
267
+
268
+ # Verify credentials were used in the request
269
+ expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=buffered-project').
270
+ with(
271
+ :headers => {
272
+ 'Authorization' => 'Basic ' + Base64.strict_encode64('buffered-user:buffered-secret')
273
+ }
274
+ ).once
275
+ end
276
+ end
277
+
278
+ end
279
+
280
+ describe 'Connection error handling' do
281
+ it 'should raise ConnectionError when network error occurs' do
282
+ stub_request(:any, 'https://api.mixpanel.com/track').to_raise(StandardError.new('Network timeout'))
283
+ consumer = Mixpanel::Consumer.new
284
+
285
+ expect {
286
+ consumer.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json)
287
+ }.to raise_error(Mixpanel::ConnectionError, /Could not connect to Mixpanel, with error "Network timeout"/)
288
+ end
208
289
  end
@@ -0,0 +1,108 @@
1
+ require 'mixpanel-ruby'
2
+ require 'json'
3
+
4
+ describe 'Credentials Security' do
5
+ describe 'import with constructor credentials (recommended)' do
6
+ it 'should NOT include credentials in message payload' do
7
+ messages = []
8
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
9
+
10
+ # Create tracker with credentials in constructor
11
+ tracker = Mixpanel::Tracker.new('TOKEN', nil, credentials: credentials) do |type, message|
12
+ messages << [type, message]
13
+ end
14
+
15
+ # Call import with nil api_key (recommended pattern)
16
+ tracker.import(nil, 'user123', 'Test Event', {'prop' => 'value'})
17
+
18
+ # Verify message was sent
19
+ expect(messages.length).to eq(1)
20
+ type, message_json = messages[0]
21
+ expect(type).to eq(:import)
22
+
23
+ # Parse the message
24
+ message = JSON.parse(message_json)
25
+
26
+ # CRITICAL: Credentials should NOT be in the message
27
+ expect(message).not_to have_key('credentials')
28
+ expect(message_json).not_to include('test-secret')
29
+ expect(message_json).not_to include('test-user')
30
+
31
+ # Message should only contain data
32
+ expect(message).to have_key('data')
33
+ expect(message['data']).to have_key('event')
34
+ expect(message['data']['event']).to eq('Test Event')
35
+ end
36
+
37
+ it 'should NOT include credentials in buffered messages' do
38
+ messages = []
39
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
40
+
41
+ # Create buffered consumer with credentials
42
+ consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 5, credentials: credentials) do |type, message|
43
+ messages << [type, message]
44
+ end
45
+
46
+ # Send multiple event messages (events are buffered, imports are not)
47
+ 5.times do |i|
48
+ consumer.send!(:event, {'data' => {'event' => "Event #{i}"}}.to_json)
49
+ end
50
+
51
+ # Verify batched message was sent (auto-flushed when buffer reaches max_length)
52
+ expect(messages.length).to eq(1)
53
+ type, message_json = messages[0]
54
+
55
+ # CRITICAL: Credentials should NOT be in the batched message
56
+ expect(message_json).not_to include('credentials')
57
+ expect(message_json).not_to include('test-secret')
58
+ expect(message_json).not_to include('test-user')
59
+ end
60
+ end
61
+
62
+ describe 'tracking events' do
63
+ it 'should never include credentials in event messages' do
64
+ messages = []
65
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
66
+
67
+ tracker = Mixpanel::Tracker.new('TOKEN', nil, credentials: credentials) do |type, message|
68
+ messages << [type, message]
69
+ end
70
+
71
+ # Track a regular event
72
+ tracker.track('user123', 'Test Event', {'prop' => 'value'})
73
+
74
+ # Verify message
75
+ expect(messages.length).to eq(1)
76
+ type, message_json = messages[0]
77
+ expect(type).to eq(:event)
78
+
79
+ # Credentials should NEVER be in event messages
80
+ expect(message_json).not_to include('credentials')
81
+ expect(message_json).not_to include('test-secret')
82
+ end
83
+ end
84
+
85
+ describe 'legacy API key pattern' do
86
+ it 'should include api_key in message but not credentials' do
87
+ messages = []
88
+ tracker = Mixpanel::Tracker.new('TOKEN') do |type, message|
89
+ messages << [type, message]
90
+ end
91
+
92
+ # Use legacy API key pattern
93
+ tracker.import('MY_API_KEY', 'user123', 'Test Event', {'prop' => 'value'})
94
+
95
+ # Verify message
96
+ expect(messages.length).to eq(1)
97
+ type, message_json = messages[0]
98
+ message = JSON.parse(message_json)
99
+
100
+ # API key should be in message (legacy pattern)
101
+ expect(message).to have_key('api_key')
102
+ expect(message['api_key']).to eq('MY_API_KEY')
103
+
104
+ # But credentials should NOT be
105
+ expect(message).not_to have_key('credentials')
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,54 @@
1
+ require 'mixpanel-ruby'
2
+
3
+ describe Mixpanel::ServiceAccountCredentials do
4
+ describe '#initialize' do
5
+ it 'creates credentials with valid parameters' do
6
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123')
7
+ expect(credentials.username).to eq('user')
8
+ expect(credentials.secret).to eq('secret')
9
+ expect(credentials.project_id).to eq('project123')
10
+ end
11
+
12
+ it 'raises ArgumentError when username is nil' do
13
+ expect {
14
+ Mixpanel::ServiceAccountCredentials.new(nil, 'secret', 'project123')
15
+ }.to raise_error(ArgumentError, 'username is required')
16
+ end
17
+
18
+ it 'raises ArgumentError when username is empty' do
19
+ expect {
20
+ Mixpanel::ServiceAccountCredentials.new('', 'secret', 'project123')
21
+ }.to raise_error(ArgumentError, 'username is required')
22
+ end
23
+
24
+ it 'raises ArgumentError when secret is nil' do
25
+ expect {
26
+ Mixpanel::ServiceAccountCredentials.new('user', nil, 'project123')
27
+ }.to raise_error(ArgumentError, 'secret is required')
28
+ end
29
+
30
+ it 'raises ArgumentError when secret is empty' do
31
+ expect {
32
+ Mixpanel::ServiceAccountCredentials.new('user', '', 'project123')
33
+ }.to raise_error(ArgumentError, 'secret is required')
34
+ end
35
+
36
+ it 'raises ArgumentError when project_id is nil' do
37
+ expect {
38
+ Mixpanel::ServiceAccountCredentials.new('user', 'secret', nil)
39
+ }.to raise_error(ArgumentError, 'project_id is required')
40
+ end
41
+
42
+ it 'raises ArgumentError when project_id is empty' do
43
+ expect {
44
+ Mixpanel::ServiceAccountCredentials.new('user', 'secret', '')
45
+ }.to raise_error(ArgumentError, 'project_id is required')
46
+ end
47
+
48
+ it 'accepts integer project_id and converts to string' do
49
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 12345)
50
+ expect(credentials.project_id).to eq('12345')
51
+ end
52
+ end
53
+
54
+ end
@@ -3,6 +3,7 @@ require 'time'
3
3
 
4
4
  require 'mixpanel-ruby/events.rb'
5
5
  require 'mixpanel-ruby/version.rb'
6
+ require 'mixpanel-ruby/credentials.rb'
6
7
 
7
8
  describe Mixpanel::Events do
8
9
  before(:each) do
@@ -73,4 +74,155 @@ describe Mixpanel::Events do
73
74
  }
74
75
  } ]])
75
76
  end
77
+
78
+ it 'should send a well formed import/ message with service account credentials' do
79
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123')
80
+
81
+ # Create new Events instance with credentials in constructor
82
+ events_with_creds = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message|
83
+ @log << [type, JSON.load(message)]
84
+ end
85
+
86
+ # New API: pass nil for api_key, credentials come from instance
87
+ events_with_creds.import(nil, 'TEST ID', 'Test Event', {
88
+ 'Circumstances' => 'During a test'
89
+ })
90
+
91
+ expect(@log.length).to eq(1)
92
+ expect(@log[0][0]).to eq(:import)
93
+
94
+ message = @log[0][1]
95
+ # Credentials should NOT be in message (secure API)
96
+ expect(message).not_to have_key('credentials')
97
+ expect(message).not_to have_key('api_key')
98
+ expect(message['data']).to eq({
99
+ 'event' => 'Test Event',
100
+ 'properties' => {
101
+ 'Circumstances' => 'During a test',
102
+ 'distinct_id' => 'TEST ID',
103
+ 'mp_lib' => 'ruby',
104
+ '$lib_version' => Mixpanel::VERSION,
105
+ 'token' => 'TEST TOKEN',
106
+ 'time' => @time_now.to_i * 1000
107
+ }
108
+ })
109
+ end
110
+
111
+ it 'should raise ArgumentError when import is called without authentication' do
112
+ # Create Events without credentials
113
+ events = Mixpanel::Events.new('TEST TOKEN') do |type, message|
114
+ @log << [type, JSON.load(message)]
115
+ end
116
+
117
+ # Try to import with nil api_key and no credentials
118
+ expect {
119
+ events.import(nil, 'TEST ID', 'Test Event', {})
120
+ }.to raise_error(ArgumentError, /import requires authentication/)
121
+ end
122
+
123
+ describe 'import_events' do
124
+ it 'should send a well formed import/ message with constructor credentials' do
125
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123')
126
+
127
+ events_with_creds = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message|
128
+ @log << [type, JSON.load(message)]
129
+ end
130
+
131
+ # Clean API - no nil!
132
+ events_with_creds.import_events('TEST ID', 'Test Event', {
133
+ 'Circumstances' => 'During a test'
134
+ })
135
+
136
+ expect(@log.length).to eq(1)
137
+ expect(@log[0][0]).to eq(:import)
138
+
139
+ message = @log[0][1]
140
+ # Credentials should NOT be in message
141
+ expect(message).not_to have_key('credentials')
142
+ expect(message).not_to have_key('api_key')
143
+ expect(message['data']).to eq({
144
+ 'event' => 'Test Event',
145
+ 'properties' => {
146
+ 'Circumstances' => 'During a test',
147
+ 'distinct_id' => 'TEST ID',
148
+ 'mp_lib' => 'ruby',
149
+ '$lib_version' => Mixpanel::VERSION,
150
+ 'token' => 'TEST TOKEN',
151
+ 'time' => @time_now.to_i * 1000
152
+ }
153
+ })
154
+ end
155
+
156
+ it 'should raise ArgumentError when called without constructor credentials' do
157
+ events = Mixpanel::Events.new('TEST TOKEN') do |type, message|
158
+ @log << [type, JSON.load(message)]
159
+ end
160
+
161
+ expect {
162
+ events.import_events('TEST ID', 'Test Event', {})
163
+ }.to raise_error(ArgumentError, /import_events requires credentials in constructor/)
164
+ end
165
+ end
166
+
167
+ describe 'warnings' do
168
+ it 'should warn when credentials are passed with a custom block' do
169
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project')
170
+
171
+ expect {
172
+ Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message|
173
+ # Custom block
174
+ end
175
+ }.to output(/credentials passed to Events\/Tracker are ignored/).to_stderr
176
+ end
177
+
178
+ it 'should not warn when credentials are passed without a block' do
179
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project')
180
+
181
+ expect {
182
+ Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials)
183
+ }.not_to output.to_stderr
184
+ end
185
+
186
+ it 'should warn when using deprecated api_key in import' do
187
+ events = Mixpanel::Events.new('TEST TOKEN') do |type, message|
188
+ @log << [type, JSON.load(message)]
189
+ end
190
+
191
+ expect {
192
+ events.import('API_KEY', 'TEST ID', 'Test Event', {})
193
+ }.to output(/DEPRECATION.*api_key to import\(\) is deprecated/).to_stderr
194
+
195
+ # Second call should also warn
196
+ expect {
197
+ events.import('API_KEY', 'TEST ID', 'Test Event 2', {})
198
+ }.to output(/DEPRECATION.*api_key to import\(\) is deprecated/).to_stderr
199
+ end
200
+
201
+ it 'should warn when using import(nil, ...) with credentials' do
202
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project')
203
+ events = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message|
204
+ @log << [type, JSON.load(message)]
205
+ end
206
+
207
+ expect {
208
+ events.import(nil, 'TEST ID', 'Test Event', {})
209
+ }.to output(/DEPRECATION.*import\(nil, \.\.\.\) is deprecated.*import_events/).to_stderr
210
+
211
+ # Second call should also warn
212
+ expect {
213
+ events.import(nil, 'TEST ID', 'Test Event 2', {})
214
+ }.to output(/DEPRECATION.*import\(nil, \.\.\.\) is deprecated.*import_events/).to_stderr
215
+ end
216
+
217
+ it 'should not warn when using import_events' do
218
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project')
219
+ events = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message|
220
+ @log << [type, JSON.load(message)]
221
+ end
222
+
223
+ expect {
224
+ events.import_events('TEST ID', 'Test Event', {})
225
+ }.not_to output(/DEPRECATION/).to_stderr
226
+ end
227
+ end
76
228
  end
@@ -1,6 +1,8 @@
1
1
  require 'json'
2
+ require 'timeout'
2
3
  require 'mixpanel-ruby/flags/local_flags_provider'
3
4
  require 'mixpanel-ruby/flags/types'
5
+ require 'mixpanel-ruby/credentials'
4
6
  require 'webmock/rspec'
5
7
 
6
8
  describe Mixpanel::Flags::LocalFlagsProvider do
@@ -16,7 +18,8 @@ describe Mixpanel::Flags::LocalFlagsProvider do
16
18
  test_token,
17
19
  config,
18
20
  mock_tracker,
19
- mock_error_handler
21
+ mock_error_handler,
22
+ nil # credentials
20
23
  )
21
24
  end
22
25
 
@@ -100,6 +103,34 @@ describe Mixpanel::Flags::LocalFlagsProvider do
100
103
  }
101
104
  end
102
105
 
106
+ describe '#initialize' do
107
+ it 'raises ArgumentError when polling_interval_in_seconds is not a positive number' do
108
+ [0, -1, 'sixty', :foo].each do |bad_value|
109
+ expect do
110
+ Mixpanel::Flags::LocalFlagsProvider.new(
111
+ test_token,
112
+ { polling_interval_in_seconds: bad_value },
113
+ mock_tracker,
114
+ mock_error_handler
115
+ )
116
+ end.to raise_error(ArgumentError, /polling_interval_in_seconds/)
117
+ end
118
+ end
119
+
120
+ it 'falls back to the default polling_interval_in_seconds when caller passes nil' do
121
+ # nil must not silently override the default — CV#wait(mutex, nil) blocks
122
+ # indefinitely, which would silently disable polling.
123
+ expect do
124
+ Mixpanel::Flags::LocalFlagsProvider.new(
125
+ test_token,
126
+ { polling_interval_in_seconds: nil },
127
+ mock_tracker,
128
+ mock_error_handler
129
+ )
130
+ end.not_to raise_error
131
+ end
132
+ end
133
+
103
134
  describe '#get_variant_value' do
104
135
  it 'returns fallback when no flag definitions' do
105
136
  stub_flag_definitions([])
@@ -741,7 +772,8 @@ describe Mixpanel::Flags::LocalFlagsProvider do
741
772
  test_token,
742
773
  { enable_polling: true, polling_interval_in_seconds: 0.1 },
743
774
  mock_tracker,
744
- mock_error_handler
775
+ mock_error_handler,
776
+ nil # credentials
745
777
  )
746
778
 
747
779
  begin
@@ -755,5 +787,180 @@ describe Mixpanel::Flags::LocalFlagsProvider do
755
787
  polling_provider.stop_polling_for_definitions!
756
788
  end
757
789
  end
790
+
791
+ it 'shuts down promptly while the polling thread is waiting on the interval' do
792
+ flag = create_test_flag
793
+ stub_flag_definitions([flag])
794
+
795
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
796
+ test_token,
797
+ # A long interval ensures the polling thread is parked on the wait when
798
+ # shutdown is requested — pre-fix, the join would have to ride out the
799
+ # full interval before the thread checked @stop_polling.
800
+ { enable_polling: true, polling_interval_in_seconds: 30 },
801
+ mock_tracker,
802
+ mock_error_handler
803
+ )
804
+
805
+ polling_provider.start_polling_for_definitions!
806
+ begin
807
+ # Wait until the polling thread is parked on the condition variable
808
+ # (status 'sleep') so the spec deterministically exercises the CV-wakeup
809
+ # path.
810
+ polling_thread = polling_provider.instance_variable_get(:@polling_thread)
811
+ Timeout.timeout(2) { sleep 0.01 until polling_thread.status == 'sleep' }
812
+
813
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
814
+ polling_provider.stop_polling_for_definitions!
815
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
816
+ expect(elapsed).to be < 1.0
817
+ ensure
818
+ polling_provider.stop_polling_for_definitions!
819
+ end
820
+ end
821
+
822
+ it 'shuts down promptly when stop races an in-flight fetch' do
823
+ flag = create_test_flag
824
+ fetch_count = 0
825
+ fetch_count_mutex = Mutex.new
826
+ second_fetch_started = Queue.new
827
+ second_fetch_release = Queue.new
828
+
829
+ stub_request(:get, endpoint_url_regex)
830
+ .to_return do |_request|
831
+ this_call = fetch_count_mutex.synchronize { fetch_count += 1 }
832
+ if this_call == 2
833
+ second_fetch_started << true
834
+ second_fetch_release.pop
835
+ end
836
+ {
837
+ status: 200,
838
+ body: { code: 200, flags: [flag] }.to_json,
839
+ headers: { 'Content-Type' => 'application/json' }
840
+ }
841
+ end
842
+
843
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
844
+ test_token,
845
+ # 1 s interval — large enough that the lost-wakeup race (if reintroduced)
846
+ # would be detectable as ~1 s of extra teardown after the fetch finishes,
847
+ # but small enough that the second polling-thread fetch starts quickly.
848
+ { enable_polling: true, polling_interval_in_seconds: 1.0 },
849
+ mock_tracker,
850
+ mock_error_handler
851
+ )
852
+
853
+ polling_provider.start_polling_for_definitions!
854
+ begin
855
+ # Bounded wait: if a regression keeps the polling thread from reaching
856
+ # the second fetch, fail fast rather than hanging the suite.
857
+ Timeout.timeout(5) { second_fetch_started.pop }
858
+
859
+ # Trigger shutdown while the polling thread is mid-fetch (NOT on the
860
+ # CV), so the broadcast goes to no waiter. Run it in a thread so we can
861
+ # release the fetch afterwards.
862
+ stopper = Thread.new { polling_provider.stop_polling_for_definitions! }
863
+ # Wait until the stopper has set @stop_polling and broadcast, so
864
+ # releasing the fetch deterministically lands in the lost-wakeup window
865
+ # this spec targets.
866
+ Timeout.timeout(2) { sleep 0.01 until polling_provider.instance_variable_get(:@stop_polling) }
867
+
868
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
869
+ second_fetch_release << true
870
+ stopper.join
871
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
872
+
873
+ # With the predicate-inside-mutex check, the polling thread re-enters
874
+ # the mutex after fetch, sees @stop_polling = true, skips the wait, and
875
+ # breaks immediately. Without it, the thread would call wait(1.0 s),
876
+ # ride out the full interval, and only then break — elapsed would be
877
+ # ~1 s.
878
+ expect(elapsed).to be < 0.5
879
+ ensure
880
+ # Unblock any in-flight stub fetch in case we raised before releasing
881
+ # it normally — otherwise the polling thread stays blocked at
882
+ # second_fetch_release.pop and stop_polling_for_definitions!'s join
883
+ # would hang the suite.
884
+ second_fetch_release << true
885
+ polling_provider.stop_polling_for_definitions!
886
+ end
887
+ end
888
+
889
+ it 'stop arriving during the initial fetch wins; start does not spawn a thread' do
890
+ fetch_in_progress = Queue.new
891
+ fetch_release = Queue.new
892
+
893
+ stub_request(:get, endpoint_url_regex).to_return do |_req|
894
+ fetch_in_progress << true
895
+ fetch_release.pop
896
+ {
897
+ status: 200,
898
+ body: { code: 200, flags: [] }.to_json,
899
+ headers: { 'Content-Type' => 'application/json' }
900
+ }
901
+ end
902
+
903
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
904
+ test_token,
905
+ { enable_polling: true, polling_interval_in_seconds: 30 },
906
+ mock_tracker,
907
+ mock_error_handler
908
+ )
909
+
910
+ starter = Thread.new { polling_provider.start_polling_for_definitions! }
911
+ begin
912
+ # Wait until start is blocked inside the initial fetch.
913
+ Timeout.timeout(5) { fetch_in_progress.pop }
914
+
915
+ # Stop arrives while start is mid-fetch (start has already cleared
916
+ # @stop_polling, so this is the bad case where stop's set must "win"
917
+ # despite happening between start's clear and start's spawn-decision).
918
+ polling_provider.stop_polling_for_definitions!
919
+
920
+ # Let start finish its fetch and reach the lifecycle check.
921
+ fetch_release << true
922
+ starter.join
923
+
924
+ # No polling thread should have been spawned — stop's postcondition
925
+ # (polling is off when stop returns) must hold even when start was
926
+ # mid-fetch at the time of the stop call.
927
+ expect(polling_provider.instance_variable_get(:@polling_thread)).to be_nil
928
+ ensure
929
+ fetch_release << true
930
+ starter&.join
931
+ polling_provider.stop_polling_for_definitions!
932
+ end
933
+ end
934
+ end
935
+
936
+ describe 'service account credentials' do
937
+ it 'uses service account credentials for authentication' do
938
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
939
+ flag = create_test_flag
940
+
941
+ stub_request(:get, endpoint_url_regex)
942
+ .with(
943
+ basic_auth: ['test-user', 'test-secret']
944
+ )
945
+ .to_return(
946
+ status: 200,
947
+ body: { code: 200, flags: [flag] }.to_json,
948
+ headers: { 'Content-Type' => 'application/json' }
949
+ )
950
+
951
+ credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new(
952
+ test_token,
953
+ config,
954
+ mock_tracker,
955
+ mock_error_handler,
956
+ credentials
957
+ )
958
+
959
+ credentials_provider.start_polling_for_definitions!
960
+ result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false)
961
+
962
+ expect(result).not_to eq('fallback')
963
+ credentials_provider.stop_polling_for_definitions!
964
+ end
758
965
  end
759
966
  end