mixpanel-ruby 3.1.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.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mixpanel
4
+ module OpenFeature
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -1,3 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'openfeature/version'
3
4
  require_relative 'openfeature/provider'
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require File.join(File.dirname(__FILE__), 'lib/mixpanel/openfeature/version.rb')
4
+
3
5
  Gem::Specification.new do |spec|
4
6
  spec.name = 'mixpanel-ruby-openfeature'
5
- spec.version = '0.1.0'
7
+ spec.version = Mixpanel::OpenFeature::VERSION
6
8
  spec.authors = ['Mixpanel']
7
9
  spec.email = 'support@mixpanel.com'
8
10
  spec.summary = 'OpenFeature provider for Mixpanel feature flags'
@@ -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