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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +14 -0
- data/.github/modules.json +18 -0
- data/.github/scripts/generate-changelog.sh +87 -0
- data/.github/workflows/pr-title-check.yml +51 -0
- data/.github/workflows/prepare-release.yml +189 -0
- data/.github/workflows/release-rubygems.yml +217 -0
- data/.github/workflows/ruby.yml +31 -2
- data/CHANGELOG.md +30 -0
- data/Readme.rdoc +65 -0
- data/lib/mixpanel-ruby/consumer.rb +54 -6
- data/lib/mixpanel-ruby/credentials.rb +26 -0
- data/lib/mixpanel-ruby/events.rb +69 -11
- data/lib/mixpanel-ruby/flags/flags_provider.rb +28 -10
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +104 -40
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +4 -2
- data/lib/mixpanel-ruby/tracker.rb +39 -9
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/lib/mixpanel-ruby.rb +1 -0
- data/openfeature-provider/CHANGELOG.md +5 -0
- data/openfeature-provider/Gemfile +7 -0
- data/openfeature-provider/README.md +288 -0
- data/openfeature-provider/RELEASE.md +52 -0
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +170 -0
- data/openfeature-provider/lib/mixpanel/openfeature/version.rb +7 -0
- data/openfeature-provider/lib/mixpanel/openfeature.rb +4 -0
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +25 -0
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +606 -0
- data/openfeature-provider/spec/spec_helper.rb +23 -0
- data/spec/mixpanel-ruby/consumer_spec.rb +81 -0
- data/spec/mixpanel-ruby/credentials_security_spec.rb +108 -0
- data/spec/mixpanel-ruby/credentials_spec.rb +54 -0
- data/spec/mixpanel-ruby/events_spec.rb +152 -0
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +209 -2
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +36 -0
- data/spec/mixpanel-ruby/tracker_spec.rb +18 -0
- 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
|