diff --git a/README.md b/README.md index 1b0fda2..58f8598 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,78 @@ Gzip compression can reduce bandwidth usage and improve performance, especially The library supports importing historical events (events older than 5 days that are not accepted using /track) via the `/import` endpoint. Project token will be used for basic auth. +### Service Account Authentication (Recommended) + +**Service account authentication is the recommended method for server-side integrations.** + +Service accounts provide enhanced security by using unique username/secret pairs instead of relying solely on the project token for authentication: + +```java +import com.mixpanel.mixpanelapi.*; + +// Create service account credentials +ServiceAccountCredential credentials = new ServiceAccountCredential( + 12345L, // project ID + "service-username", // service account username + "service-secret" // service account secret +); + +// Configure MixpanelAPI with credentials +MixpanelAPI mixpanel = new MixpanelAPI.Builder() + .credentials(credentials) + .build(); + +// Use normally - credentials are used for /import endpoint and feature flags +MessageBuilder messages = new MessageBuilder("my token"); +JSONObject event = messages.event("user@example.com", "Signup", null); + +ClientDelivery delivery = new ClientDelivery(); +delivery.addImportMessage(event); // This will use service account auth + +mixpanel.deliver(delivery); +``` + +### Service Accounts with Feature Flags + +Service account credentials are passed to MixpanelAPI and automatically used for feature flags: + +```java +import com.mixpanel.mixpanelapi.*; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; + +// Create service account credentials +ServiceAccountCredential credentials = new ServiceAccountCredential( + 12345L, "service-username", "service-secret" +); + +// Configure feature flags +LocalFlagsConfig flagsConfig = LocalFlagsConfig.builder() + .projectToken("my-token") + .pollingIntervalSeconds(60) + .build(); + +// Pass credentials to MixpanelAPI - they'll be used for both /import and feature flags +MixpanelAPI mixpanel = new MixpanelAPI.Builder() + .credentials(credentials) // Credentials used for /import AND feature flags + .flagsConfig(flagsConfig) + .build(); + +// Feature flag requests will use service account authentication +mixpanel.getLocalFlags().startPollingForDefinitions(); +boolean isEnabled = mixpanel.getLocalFlags().isEnabled("new-feature", context); +``` + +**Important Notes:** +- **Recommended for all new integrations** - Service accounts provide enhanced security +- Service account credentials are used for: + - **`/import` endpoint** - Historical event imports + - **Feature flags** - `/flags` and `/flags/definitions` endpoints +- Regular event tracking (`/track`), people updates (`/engage`), and group updates (`/groups`) continue to use the project token included in the message payload +- When service account credentials are configured: + - Authenticated endpoints use HTTP Basic Authentication with `username:secret` + - The `project_id` is included as a query parameter instead of `token` + - The project token from the message is not used for authentication (but should still be included in tracking events) + ### High-Performance JSON Serialization (Optional) For applications that import large batches of events (e.g., using the `/import` endpoint), the library supports optional high-performance JSON serialization using Jackson. The Jackson extension provides **up to 5x performance improvement** for large batches. diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java index 7a4fe23..2818e11 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java @@ -59,6 +59,7 @@ public class MixpanelAPI implements AutoCloseable { protected final RemoteFlagsProvider mRemoteFlags; protected final JsonSerializer mJsonSerializer; protected final OrgJsonSerializer mDefaultJsonSerializer; + protected final ServiceAccountCredential mCredentials; /** * Constructs a MixpanelAPI object associated with the production, Mixpanel services. @@ -73,7 +74,7 @@ public MixpanelAPI() { * @param useGzipCompression whether to use gzip compression for network requests */ public MixpanelAPI(boolean useGzipCompression) { - this(null, null, null, null, useGzipCompression, null, null, null, null, null, null); + this(null, null, null, null, useGzipCompression, null, null, null, null, null, null, null); } /** @@ -102,7 +103,7 @@ public MixpanelAPI(RemoteFlagsConfig remoteFlagsConfig) { * @param remoteFlagsConfig configuration for remote feature flags evaluation (can be null) */ private MixpanelAPI(LocalFlagsConfig localFlagsConfig, RemoteFlagsConfig remoteFlagsConfig) { - this(null, null, null, null, false, localFlagsConfig, remoteFlagsConfig, null, null, null, null); + this(null, null, null, null, false, localFlagsConfig, remoteFlagsConfig, null, null, null, null, null); } /** @@ -115,7 +116,7 @@ private MixpanelAPI(LocalFlagsConfig localFlagsConfig, RemoteFlagsConfig remoteF * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint) { - this(eventsEndpoint, peopleEndpoint, null, null, false, null, null, null, null, null, null); + this(eventsEndpoint, peopleEndpoint, null, null, false, null, null, null, null, null, null, null); } /** @@ -129,7 +130,7 @@ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint) { * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint) { - this(eventsEndpoint, peopleEndpoint, groupsEndpoint, null, false, null, null, null, null, null, null); + this(eventsEndpoint, peopleEndpoint, groupsEndpoint, null, false, null, null, null, null, null, null, null); } /** @@ -144,7 +145,7 @@ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEn * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint, String importEndpoint) { - this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, false, null, null, null, null, null, null); + this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, false, null, null, null, null, null, null, null); } /** @@ -160,7 +161,7 @@ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEn * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint, String importEndpoint, boolean useGzipCompression) { - this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, useGzipCompression, null, null, null, null, null, null); + this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, useGzipCompression, null, null, null, null, null, null, null); } /** @@ -180,7 +181,8 @@ private MixpanelAPI(Builder builder) { builder.jsonSerializer, builder.connectTimeout, builder.readTimeout, - builder.importMaxMessageCount + builder.importMaxMessageCount, + builder.credentials ); } @@ -198,6 +200,7 @@ private MixpanelAPI(Builder builder) { * @param connectTimeout connection timeout in milliseconds (null uses default) * @param readTimeout read timeout in milliseconds (null uses default) * @param importMaxMessageCount maximum messages per import batch (null uses default) + * @param credentials service account credentials for authentication (null uses token-based auth) */ private MixpanelAPI( String eventsEndpoint, @@ -210,7 +213,8 @@ private MixpanelAPI( JsonSerializer jsonSerializer, Integer connectTimeout, Integer readTimeout, - Integer importMaxMessageCount + Integer importMaxMessageCount, + ServiceAccountCredential credentials ) { mEventsEndpoint = eventsEndpoint != null ? eventsEndpoint : Config.BASE_ENDPOINT + "/track"; mPeopleEndpoint = peopleEndpoint != null ? peopleEndpoint : Config.BASE_ENDPOINT + "/engage"; @@ -221,6 +225,7 @@ private MixpanelAPI( mReadTimeout = readTimeout != null ? readTimeout : DEFAULT_READ_TIMEOUT_MILLIS; mImportMaxMessageCount = importMaxMessageCount != null ? Math.min(importMaxMessageCount, Config.IMPORT_MAX_MESSAGE_SIZE) : Config.IMPORT_MAX_MESSAGE_SIZE; + mCredentials = credentials; mDefaultJsonSerializer = new OrgJsonSerializer(); if (jsonSerializer != null) { logger.log(Level.INFO, "Custom JsonSerializer provided: " + jsonSerializer.getClass().getName()); @@ -231,12 +236,12 @@ private MixpanelAPI( if (localFlagsConfig != null) { EventSender eventSender = createEventSender(localFlagsConfig, this); - mLocalFlags = new LocalFlagsProvider(localFlagsConfig, VersionUtil.getVersion(), eventSender); + mLocalFlags = new LocalFlagsProvider(localFlagsConfig, VersionUtil.getVersion(), eventSender, credentials); mRemoteFlags = null; } else if (remoteFlagsConfig != null) { EventSender eventSender = createEventSender(remoteFlagsConfig, this); mLocalFlags = null; - mRemoteFlags = new RemoteFlagsProvider(remoteFlagsConfig, VersionUtil.getVersion(), eventSender); + mRemoteFlags = new RemoteFlagsProvider(remoteFlagsConfig, VersionUtil.getVersion(), eventSender, credentials); } else { mLocalFlags = null; mRemoteFlags = null; @@ -492,20 +497,32 @@ private String dataString(List messages) { } /** - * Sends import data to the /import endpoint with Basic Auth using the project token. + * Sends import data to the /import endpoint with authentication. + *

+ * When service account credentials are configured, uses HTTP Basic Auth with + * username:secret and includes project_id as a query parameter. + * Otherwise, uses token-based Basic Auth (token as username, empty password). + *

* The /import endpoint requires: * - JSON content type (not URL-encoded like /track) - * - Basic authentication with token as username and empty password - * - strict=1 parameter for validation + * - Basic authentication (either service account or token) + * - strict parameter for validation mode * * @param dataString JSON array of events to import * @param endpointUrl The import endpoint URL - * @param token The project token for Basic Auth + * @param token The project token (used only when service account credentials are not configured) * @return true if the server accepted the data * @throws IOException if there's a network error */ /* package */ boolean sendImportData(String dataString, String endpointUrl, String token) throws IOException { - URL endpoint = new URL(endpointUrl); + // If service account credentials are configured, add project_id as query parameter + String finalEndpointUrl = endpointUrl; + if (mCredentials != null) { + String separator = endpointUrl.contains("?") ? "&" : "?"; + finalEndpointUrl = endpointUrl + separator + "project_id=" + mCredentials.getProjectId(); + } + + URL endpoint = new URL(finalEndpointUrl); HttpURLConnection conn = (HttpURLConnection) endpoint.openConnection(); conn.setReadTimeout(mReadTimeout); conn.setConnectTimeout(mConnectTimeout); @@ -513,9 +530,16 @@ private String dataString(List messages) { conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); - // Add Basic Auth header: username is token, password is empty + // Add Basic Auth header try { - String authString = token + ":"; + String authString; + if (mCredentials != null) { + // Service account auth: username:secret + authString = mCredentials.getUsername() + ":" + mCredentials.getSecret(); + } else { + // Token-based auth: token:empty + authString = token + ":"; + } byte[] authBytes = authString.getBytes("utf-8"); String base64Auth = new String(Base64Coder.encode(authBytes)); conn.setRequestProperty("Authorization", "Basic " + base64Auth); @@ -734,6 +758,7 @@ public static class Builder { private Integer connectTimeout; private Integer readTimeout; private Integer importMaxMessageCount; + private ServiceAccountCredential credentials; /** * Sets the endpoint URL for Mixpanel events messages. @@ -859,6 +884,33 @@ public Builder importMaxMessageCount(int importMaxMessageCount) { return this; } + /** + * Sets the service account credentials for authentication. + *

+ * Recommended: Service account authentication is the preferred method + * for server-side integrations. Service accounts provide enhanced security by using + * unique username/secret pairs instead of relying solely on the project token for + * authentication. + *

+ *

+ * When provided, the /import endpoint will use HTTP Basic Authentication with the + * service account username and secret instead of token-based authentication. + *

+ *

+ * Service account credentials are only applied to the /import endpoint (and feature flags). + * Regular event tracking operations (track, people updates, group updates) continue to + * use the project token included in the message payload. + *

+ * + * @param credentials service account credentials for authentication + * @return this Builder instance for method chaining + * @see ServiceAccountCredential + */ + public Builder credentials(ServiceAccountCredential credentials) { + this.credentials = credentials; + return this; + } + /** * Builds and returns a new MixpanelAPI instance with the configured settings. * diff --git a/src/main/java/com/mixpanel/mixpanelapi/ServiceAccountCredential.java b/src/main/java/com/mixpanel/mixpanelapi/ServiceAccountCredential.java new file mode 100644 index 0000000..b252124 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/ServiceAccountCredential.java @@ -0,0 +1,71 @@ +package com.mixpanel.mixpanelapi; + +/** + * Encapsulates service account credentials for server-to-server authentication. + *

+ * Recommended: Service account authentication is the preferred method for + * server-side integrations. Service accounts provide enhanced security by using unique + * username/secret pairs instead of relying solely on the project token for authentication. + *

+ *

+ * Service accounts use a project ID, username, and secret for authentication. + * This class ensures all required credential fields are provided together. + *

+ *

+ * Service account credentials are only used for the /import endpoint (and feature flags). + * Regular event tracking operations (track, people updates, group updates) use the project + * token provided in the message payload. + *

+ * + * @see MixpanelAPI.Builder#credentials(ServiceAccountCredential) + */ +public final class ServiceAccountCredential { + private final long projectId; + private final String username; + private final String secret; + + /** + * Creates a new ServiceAccountCredential. + * + * @param projectId the Mixpanel project ID + * @param username the service account username + * @param secret the service account secret + * @throws IllegalArgumentException if projectId is invalid or username/secret are null or empty + */ + public ServiceAccountCredential(long projectId, String username, String secret) { + if (projectId <= 0) { + throw new IllegalArgumentException("projectId must be greater than zero"); + } + if (username == null || username.trim().isEmpty()) { + throw new IllegalArgumentException("username cannot be null or empty"); + } + if (secret == null || secret.trim().isEmpty()) { + throw new IllegalArgumentException("secret cannot be null or empty"); + } + + this.projectId = projectId; + this.username = username; + this.secret = secret; + } + + /** + * @return the Mixpanel project ID + */ + public long getProjectId() { + return projectId; + } + + /** + * @return the service account username + */ + public String getUsername() { + return username; + } + + /** + * @return the service account secret + */ + public String getSecret() { + return secret; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java index 9e3f4ed..89f05c4 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java @@ -1,5 +1,6 @@ package com.mixpanel.mixpanelapi.featureflags.provider; +import com.mixpanel.mixpanelapi.ServiceAccountCredential; import com.mixpanel.mixpanelapi.featureflags.EventSender; import com.mixpanel.mixpanelapi.featureflags.config.BaseFlagsConfig; import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; @@ -35,6 +36,7 @@ public abstract class BaseFlagsProvider { protected final C config; protected final String sdkVersion; protected final EventSender eventSender; + protected final ServiceAccountCredential credentials; /** * Creates a new BaseFlagsProvider. @@ -43,12 +45,14 @@ public abstract class BaseFlagsProvider { * @param config the flags configuration * @param sdkVersion the SDK version string * @param eventSender the EventSender implementation for tracking exposure events + * @param credentials service account credentials for authentication (may be null) */ - protected BaseFlagsProvider(String projectToken, C config, String sdkVersion, EventSender eventSender) { + protected BaseFlagsProvider(String projectToken, C config, String sdkVersion, EventSender eventSender, ServiceAccountCredential credentials) { this.projectToken = projectToken; this.config = config; this.sdkVersion = sdkVersion; this.eventSender = eventSender; + this.credentials = credentials; } // #region HTTP Methods @@ -56,6 +60,10 @@ protected BaseFlagsProvider(String projectToken, C config, String sdkVersion, Ev /** * Performs an HTTP GET request with Basic Auth. *

+ * When service account credentials are configured, uses username:secret for authentication. + * Otherwise, uses token-based authentication (token as username, empty password). + *

+ *

* This method is protected to allow test subclasses to override HTTP behavior. *

*/ @@ -65,8 +73,15 @@ protected String httpGet(String urlString) throws IOException { conn.setConnectTimeout(config.getRequestTimeoutSeconds() * 1000); conn.setReadTimeout(config.getRequestTimeoutSeconds() * 1000); - // Set Basic Auth header (token as username, empty password) - String auth = projectToken + ":"; + // Set Basic Auth header + String auth; + if (credentials != null) { + // Service account auth: username:secret + auth = credentials.getUsername() + ":" + credentials.getSecret(); + } else { + // Token-based auth: token:empty + auth = projectToken + ":"; + } String encodedAuth = java.util.Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); conn.setRequestProperty("Authorization", "Basic " + encodedAuth); diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 50357b0..2934e78 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -42,14 +42,26 @@ public class LocalFlagsProvider extends BaseFlagsProvider impl private ScheduledExecutorService pollingExecutor; /** - * Creates a new LocalFlagsProvider. + * Creates a new LocalFlagsProvider without credentials. * * @param config the local flags configuration * @param sdkVersion the SDK version string * @param eventSender the EventSender implementation for tracking exposure events */ public LocalFlagsProvider(LocalFlagsConfig config, String sdkVersion, EventSender eventSender) { - super(config.getProjectToken(), config, sdkVersion, eventSender); + this(config, sdkVersion, eventSender, null); + } + + /** + * Creates a new LocalFlagsProvider. + * + * @param config the local flags configuration + * @param sdkVersion the SDK version string + * @param eventSender the EventSender implementation for tracking exposure events + * @param credentials service account credentials for authentication (may be null) + */ + public LocalFlagsProvider(LocalFlagsConfig config, String sdkVersion, EventSender eventSender, com.mixpanel.mixpanelapi.ServiceAccountCredential credentials) { + super(config.getProjectToken(), config, sdkVersion, eventSender, credentials); this.flagDefinitions = new AtomicReference<>(new HashMap<>()); this.ready = new AtomicBoolean(false); @@ -141,13 +153,23 @@ private void fetchDefinitions() { /** * Builds the URL for fetching flag definitions. + *

+ * Always includes token parameter. When service account credentials are configured, + * also includes project_id parameter. + *

*/ private String buildDefinitionsUrl() throws UnsupportedEncodingException { StringBuilder url = new StringBuilder(); url.append("https://").append(config.getApiHost()).append("/flags/definitions"); - url.append("?mp_lib=").append(URLEncoder.encode("java", "UTF-8")); + url.append("?mp_lib=").append(URLEncoder.encode("jdk", "UTF-8")); url.append("&lib_version=").append(URLEncoder.encode(sdkVersion, "UTF-8")); url.append("&token=").append(URLEncoder.encode(projectToken, "UTF-8")); + + // Also include project_id when credentials are present + if (credentials != null) { + url.append("&project_id=").append(credentials.getProjectId()); + } + return url.toString(); } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java index 4d8fd90..451e5cf 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java @@ -30,14 +30,26 @@ public class RemoteFlagsProvider extends BaseFlagsProvider { private static final Logger logger = Logger.getLogger(RemoteFlagsProvider.class.getName()); /** - * Creates a new RemoteFlagsProvider. + * Creates a new RemoteFlagsProvider without credentials. * * @param config the remote flags configuration * @param sdkVersion the SDK version string * @param eventSender the EventSender implementation for tracking exposure events */ public RemoteFlagsProvider(RemoteFlagsConfig config, String sdkVersion, EventSender eventSender) { - super(config.getProjectToken(), config, sdkVersion, eventSender); + this(config, sdkVersion, eventSender, null); + } + + /** + * Creates a new RemoteFlagsProvider. + * + * @param config the remote flags configuration + * @param sdkVersion the SDK version string + * @param eventSender the EventSender implementation for tracking exposure events + * @param credentials service account credentials for authentication (may be null) + */ + public RemoteFlagsProvider(RemoteFlagsConfig config, String sdkVersion, EventSender eventSender, com.mixpanel.mixpanelapi.ServiceAccountCredential credentials) { + super(config.getProjectToken(), config, sdkVersion, eventSender, credentials); } // #region Evaluation @@ -118,6 +130,10 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall /** * Builds the URL for remote flag evaluation. + *

+ * Always includes token parameter. When service account credentials are configured, + * also includes project_id parameter. + *

*/ private String buildFlagsUrl(String flagKey, Map context) throws UnsupportedEncodingException { StringBuilder url = new StringBuilder(); @@ -125,6 +141,12 @@ private String buildFlagsUrl(String flagKey, Map context) throws url.append("?mp_lib=").append(URLEncoder.encode("jdk", "UTF-8")); url.append("&lib_version=").append(URLEncoder.encode(sdkVersion, "UTF-8")); url.append("&token=").append(URLEncoder.encode(projectToken, "UTF-8")); + + // Also include project_id when credentials are present + if (credentials != null) { + url.append("&project_id=").append(credentials.getProjectId()); + } + url.append("&flag_key=").append(URLEncoder.encode(flagKey, "UTF-8")); JSONObject contextJson = new JSONObject(context); diff --git a/src/test/java/com/mixpanel/mixpanelapi/MixpanelAPITest.java b/src/test/java/com/mixpanel/mixpanelapi/MixpanelAPITest.java index 14f55da..675af82 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/MixpanelAPITest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/MixpanelAPITest.java @@ -32,6 +32,8 @@ public class MixpanelAPITest extends TestCase private MessageBuilder mBuilder; private JSONObject mSampleProps; private JSONObject mSampleModifiers; + private static final String TEST_TOKEN = "a token"; + private String mEventsMessages; private String mPeopleMessages; private String mGroupMessages; @@ -59,7 +61,7 @@ public static Test suite() { @Override public void setUp() { mTimeZero = System.currentTimeMillis() / 1000; - mBuilder = new MessageBuilder("a token"); + mBuilder = new MessageBuilder(TEST_TOKEN); try { mSampleProps = new JSONObject(); @@ -1677,4 +1679,151 @@ public boolean sendData(String dataString, String endpointUrl) { api.close(); } + + /** + * Test that ServiceAccountCredential validates required fields + */ + public void testServiceAccountCredentialValidation() { + // Valid credentials should work + ServiceAccountCredential valid = new ServiceAccountCredential(12345L, "test-user", "test-secret"); + assertEquals(12345L, valid.getProjectId()); + assertEquals("test-user", valid.getUsername()); + assertEquals("test-secret", valid.getSecret()); + + // Invalid project ID (zero or negative) + try { + new ServiceAccountCredential(0L, "test-user", "test-secret"); + fail("Should reject zero project ID"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("projectId must be greater than zero")); + } + + try { + new ServiceAccountCredential(-1L, "test-user", "test-secret"); + fail("Should reject negative project ID"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("projectId must be greater than zero")); + } + + // Invalid username (null or empty) + try { + new ServiceAccountCredential(12345L, null, "test-secret"); + fail("Should reject null username"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("username cannot be null or empty")); + } + + try { + new ServiceAccountCredential(12345L, "", "test-secret"); + fail("Should reject empty username"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("username cannot be null or empty")); + } + + try { + new ServiceAccountCredential(12345L, " ", "test-secret"); + fail("Should reject whitespace-only username"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("username cannot be null or empty")); + } + + // Invalid secret (null or empty) + try { + new ServiceAccountCredential(12345L, "test-user", null); + fail("Should reject null secret"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("secret cannot be null or empty")); + } + + try { + new ServiceAccountCredential(12345L, "test-user", ""); + fail("Should reject empty secret"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("secret cannot be null or empty")); + } + + try { + new ServiceAccountCredential(12345L, "test-user", " "); + fail("Should reject whitespace-only secret"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("secret cannot be null or empty")); + } + } + + /** + * Test that service account credentials can be configured via Builder + */ + public void testServiceAccountAuthenticationForImport() { + final ServiceAccountCredential credentials = new ServiceAccountCredential(12345L, "test-user", "test-secret"); + + MixpanelAPI api = new MixpanelAPI.Builder() + .credentials(credentials) + .build(); + + // Verify credentials are stored internally + try { + java.lang.reflect.Field credField = MixpanelAPI.class.getDeclaredField("mCredentials"); + credField.setAccessible(true); + ServiceAccountCredential storedCreds = (ServiceAccountCredential) credField.get(api); + + assertNotNull("Credentials should be stored", storedCreds); + assertEquals("Project ID should match", 12345L, storedCreds.getProjectId()); + assertEquals("Username should match", "test-user", storedCreds.getUsername()); + assertEquals("Secret should match", "test-secret", storedCreds.getSecret()); + } catch (Exception e) { + fail("Failed to verify credentials: " + e.getMessage()); + } + + api.close(); + } + + /** + * Test that token-based authentication still works without credentials (backward compatibility) + */ + public void testTokenBasedAuthenticationForImport() { + // Build MixpanelAPI without credentials - should use token-based auth + MixpanelAPI api = new MixpanelAPI.Builder().build(); + + // Verify API constructs successfully without credentials (backward compatibility) + assertNotNull("API should be created without credentials", api); + + api.close(); + } + + /** + * Test that Builder correctly configures credentials alongside other options + */ + public void testServiceAccountNotUsedForTracking() { + final ServiceAccountCredential credentials = new ServiceAccountCredential(12345L, "test-user", "test-secret"); + + MixpanelAPI api = new MixpanelAPI.Builder() + .credentials(credentials) + .useGzipCompression(true) + .connectTimeout(5000) + .readTimeout(15000) + .build(); + + // Service account credentials should be configured successfully alongside other options + assertNotNull("API should be created with credentials", api); + + api.close(); + } + + /** + * Test that Builder correctly configures credentials + */ + public void testBuilderWithCredentials() { + ServiceAccountCredential credentials = new ServiceAccountCredential(12345L, "test-user", "test-secret"); + + MixpanelAPI api = new MixpanelAPI.Builder() + .credentials(credentials) + .useGzipCompression(true) + .connectTimeout(5000) + .readTimeout(15000) + .build(); + + assertNotNull("API should be created with credentials", api); + api.close(); + } + }