diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb6045b4..95bd1c5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added +- Added `updateAuthToken(String)` method for updating the auth token without triggering login side effects (push registration, in-app sync, embedded sync). Use this when you only need to refresh the token for an already logged-in user. + +### Deprecated +- `setAuthToken(String)` is now deprecated. It still triggers login operations (push registration, in-app sync, embedded sync) for backward compatibility, but will be changed to only store the token in a future release. Migrate to `updateAuthToken(String)` to update the token without side effects, or use `setEmail(email, authToken)` / `setUserId(userId, authToken)` to set credentials and trigger login operations. + ## [3.6.5] ### Fixed - Fixed IterableEmbeddedView not having an empty constructor and causing crashes diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java b/iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java new file mode 100644 index 000000000..ac4021e59 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java @@ -0,0 +1,27 @@ +package com.iterable.iterableapi; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +class ApiEndpointClassification { + + private static final Set DEFAULT_UNAUTHENTICATED = new HashSet<>(Arrays.asList( + IterableConstants.ENDPOINT_DISABLE_DEVICE, + IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION, + IterableConstants.ENDPOINT_MERGE_USER, + IterableConstants.ENDPOINT_CRITERIA_LIST, + IterableConstants.ENDPOINT_TRACK_UNKNOWN_SESSION, + IterableConstants.ENDPOINT_TRACK_CONSENT + )); + + private volatile Set unauthenticatedPaths = new HashSet<>(DEFAULT_UNAUTHENTICATED); + + boolean requiresJwt(String path) { + return !unauthenticatedPaths.contains(path); + } + + void updateFromRemoteConfig(Set paths) { + this.unauthenticatedPaths = new HashSet<>(paths); + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 319968d6b..6d3f232aa 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -43,10 +43,12 @@ public class IterableApi { private IterableNotificationData _notificationData; private String _deviceId; private boolean _firstForegroundHandled; + private boolean _autoRetryOnJwtFailure; private IterableHelper.SuccessHandler _setUserSuccessCallbackHandler; private IterableHelper.FailureHandler _setUserFailureCallbackHandler; IterableApiClient apiClient = new IterableApiClient(new IterableApiAuthProvider()); + final ApiEndpointClassification apiEndpointClassification = new ApiEndpointClassification(); private static final UnknownUserMerge unknownUserMerge = new UnknownUserMerge(); private @Nullable UnknownUserManager unknownUserManager; private @Nullable IterableInAppManager inAppManager; @@ -104,6 +106,14 @@ public void execute(@Nullable String data) { SharedPreferences sharedPref = sharedInstance.getMainActivityContext().getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE); SharedPreferences.Editor editor = sharedPref.edit(); editor.putBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, offlineConfiguration); + + // Parse autoRetry flag from remote config. + if (jsonData.has(IterableConstants.KEY_AUTO_RETRY)) { + boolean autoRetryRemote = jsonData.getBoolean(IterableConstants.KEY_AUTO_RETRY); + editor.putBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, autoRetryRemote); + _autoRetryOnJwtFailure = autoRetryRemote; + } + editor.apply(); } catch (JSONException e) { IterableLogger.e(TAG, "Failed to read remote configuration"); @@ -127,7 +137,7 @@ public String getAuthToken() { private void checkAndUpdateAuthToken(@Nullable String authToken) { // If authHandler exists and if authToken is new, it will be considered as a call to update the authToken. if (config.authHandler != null && authToken != null && authToken != _authToken) { - setAuthToken(authToken); + updateAuthToken(authToken); } } @@ -194,6 +204,15 @@ static void loadLastSavedConfiguration(Context context) { SharedPreferences sharedPref = context.getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE); boolean offlineMode = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, false); sharedInstance.apiClient.setOfflineProcessingEnabled(offlineMode); + + sharedInstance._autoRetryOnJwtFailure = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, false); + } + + /** + * Returns whether auto-retry on JWT failure is enabled, as determined by remote configuration. + */ + boolean isAutoRetryOnJwtFailure() { + return _autoRetryOnJwtFailure; } /** @@ -406,20 +425,24 @@ private void onLogin( @Nullable IterableHelper.FailureHandler failureHandler ) { if (!isInitialized()) { - setAuthToken(null); + updateAuthToken(null); return; } getAuthManager().pauseAuthRetries(false); if (authToken != null) { - setAuthToken(authToken); + updateAuthToken(authToken); + completeUserLogin(); attemptMergeAndEventReplay(userIdOrEmail, isEmail, merge, replay, isUnknown, failureHandler); } else { - getAuthManager().requestNewAuthToken(false, data -> attemptMergeAndEventReplay(userIdOrEmail, isEmail, merge, replay, isUnknown, failureHandler)); + getAuthManager().requestNewAuthToken(false, data -> { + completeUserLogin(); + attemptMergeAndEventReplay(userIdOrEmail, isEmail, merge, replay, isUnknown, failureHandler); + }); } } - private void completeUserLogin() { + void completeUserLogin() { completeUserLogin(_email, _userId, _authToken); } @@ -660,19 +683,16 @@ public void resetAuth() { //region API functions (private/internal) //--------------------------------------------------------------------------------------- - void setAuthToken(String authToken, boolean bypassAuth) { + + /** + * Updates the auth token without triggering login side effects (push registration, in-app sync, etc.). + * Use this method when you only need to update the token for an already logged-in user. + * For initial login, use {@code setEmail(email, authToken)} or {@code setUserId(userId, authToken)}. + */ + public void updateAuthToken(@Nullable String authToken) { if (isInitialized()) { - if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) { - _authToken = authToken; - // SECURITY: Use completion handler to atomically store and pass validated credentials. - // The completion handler receives exact values stored to keychain, preventing TOCTOU - // attacks where keychain could be modified between storage and completeUserLogin execution. - storeAuthData((email, userId, token) -> completeUserLogin(email, userId, token)); - } else if (bypassAuth) { - // SECURITY: Pass current credentials directly to completeUserLogin. - // completeUserLogin will validate authToken presence when JWT auth is enabled. - completeUserLogin(_email, _userId, _authToken); - } + _authToken = authToken; + storeAuthData(); } } @@ -1056,6 +1076,9 @@ public void setEmail(@Nullable String email, @Nullable String authToken, @Nullab if (_email != null && _email.equals(email)) { checkAndUpdateAuthToken(authToken); + _setUserSuccessCallbackHandler = successHandler; + _setUserFailureCallbackHandler = failureHandler; + onLogin(authToken, email, true, merge, replay, false, failureHandler); return; } @@ -1126,6 +1149,9 @@ public void setUserId(@Nullable String userId, @Nullable String authToken, @Null if (_userId != null && _userId.equals(userId)) { checkAndUpdateAuthToken(authToken); + _setUserSuccessCallbackHandler = successHandler; + _setUserFailureCallbackHandler = failureHandler; + onLogin(authToken, userId, false, merge, replay, isUnknown, failureHandler); return; } @@ -1192,8 +1218,24 @@ private void attemptAndProcessMerge(@NonNull String destinationUser, boolean isE }); } - public void setAuthToken(String authToken) { - setAuthToken(authToken, false); + /** + * Sets the auth token and triggers login operations (push registration, in-app sync, embedded sync). + * + * @deprecated This method triggers login side effects beyond just setting the token. + * To update the auth token without login side effects, use {@link #updateAuthToken(String)}. + * To set credentials and trigger login operations, use {@code setEmail(email, authToken)} + * or {@code setUserId(userId, authToken)}. + * In a future release, this method will only store the auth token without triggering login operations. + */ + @Deprecated + public void setAuthToken(@Nullable String authToken) { + if (isInitialized()) { + IterableLogger.w(TAG, "setAuthToken() is deprecated. Use updateAuthToken() to update the token, " + + "or setEmail(email, authToken) / setUserId(userId, authToken) for login. " + + "In a future release, this method will only store the auth token without triggering login operations."); + _authToken = authToken; + storeAuthData(this::completeUserLogin); + } } /** diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index bdb3a2578..e4b941cb5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -52,17 +52,20 @@ private RequestProcessor getRequestProcessor() { } void setOfflineProcessingEnabled(boolean offlineMode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (offlineMode) { - if (this.requestProcessor == null || this.requestProcessor.getClass() != OfflineRequestProcessor.class) { - this.requestProcessor = new OfflineRequestProcessor(authProvider.getContext()); - } - } else { - if (this.requestProcessor == null || this.requestProcessor.getClass() != OnlineRequestProcessor.class) { - this.requestProcessor = new OnlineRequestProcessor(); - } - } + if (offlineMode && this.requestProcessor instanceof OfflineRequestProcessor) { + return; } + if (!offlineMode && this.requestProcessor instanceof OnlineRequestProcessor) { + return; + } + + if (this.requestProcessor instanceof OfflineRequestProcessor) { + ((OfflineRequestProcessor) this.requestProcessor).dispose(); + } + + this.requestProcessor = offlineMode + ? new OfflineRequestProcessor(authProvider.getContext()) + : new OnlineRequestProcessor(); } void getRemoteConfiguration(IterableHelper.IterableActionHandler actionHandler) { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 915dbbb2a..085f885c4 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -9,6 +9,7 @@ import org.json.JSONObject; import java.io.UnsupportedEncodingException; +import java.util.ArrayList; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutorService; @@ -18,6 +19,25 @@ public class IterableAuthManager implements IterableActivityMonitor.AppStateCall private static final String TAG = "IterableAuth"; private static final String expirationString = "exp"; + /** + * Represents the state of the JWT auth token. + * VALID: Last request succeeded with this token. + * INVALID: A 401 JWT error was received; processing should pause. + * UNKNOWN: A new token was obtained but not yet verified by a request. + */ + enum AuthState { + VALID, + INVALID, + UNKNOWN + } + + /** + * Listener interface for components that need to react when a new auth token is ready. + */ + interface AuthTokenReadyListener { + void onAuthTokenReady(); + } + private final IterableApi api; private final IterableAuthHandler authHandler; private final long expiringAuthTokenRefreshPeriod; @@ -34,6 +54,10 @@ public class IterableAuthManager implements IterableActivityMonitor.AppStateCall private volatile boolean isTimerScheduled; private volatile boolean isInForeground = true; // Assume foreground initially + private volatile AuthState authState = AuthState.UNKNOWN; + private final Object timerLock = new Object(); + private final ArrayList authTokenReadyListeners = new ArrayList<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, RetryPolicy authRetryPolicy, long expiringAuthTokenRefreshPeriod) { @@ -45,6 +69,73 @@ public class IterableAuthManager implements IterableActivityMonitor.AppStateCall this.activityMonitor.addCallback(this); } + void addAuthTokenReadyListener(AuthTokenReadyListener listener) { + authTokenReadyListeners.add(listener); + } + + void removeAuthTokenReadyListener(AuthTokenReadyListener listener) { + authTokenReadyListeners.remove(listener); + } + + /** + * Returns true if the auth token is in a state that allows requests to proceed. + * Requests can proceed when auth state is VALID or UNKNOWN (newly obtained token). + * If no authHandler is configured (JWT not used), this always returns true. + */ + boolean isAuthTokenReady() { + if (authHandler == null) { + return true; + } + return authState != AuthState.INVALID; + } + + /** + * Marks the auth token as invalid. Called when a 401 JWT error is received. + */ + void setAuthTokenInvalid() { + setAuthState(AuthState.INVALID); + } + + /** + * Handles a server-side JWT rejection (401). Invalidates the current token, + * clears any pending refresh, and schedules a new token request using the retry policy. + * When the new token arrives, AuthTokenReadyListeners are notified via the + * INVALID → UNKNOWN state transition. + */ + void handleAuthTokenRejection() { + setAuthState(AuthState.INVALID); + setIsLastAuthTokenValid(false); + clearRefreshTimer(); + resetFailedAuth(); + long retryInterval = getNextRetryInterval(); + scheduleAuthTokenRefresh(retryInterval, false, null); + } + + AuthState getAuthState() { + return authState; + } + + /** + * Centralized auth state setter. Notifies AuthTokenReadyListeners only when + * transitioning from INVALID to a ready state (UNKNOWN or VALID), which means + * a new token has been obtained after a prior auth failure. + */ + private void setAuthState(AuthState newState) { + AuthState previousState = this.authState; + this.authState = newState; + + if (previousState == AuthState.INVALID && newState != AuthState.INVALID) { + notifyAuthTokenReadyListeners(); + } + } + + private void notifyAuthTokenReadyListeners() { + ArrayList listenersCopy = new ArrayList<>(authTokenReadyListeners); + for (AuthTokenReadyListener listener : listenersCopy) { + listener.onAuthTokenReady(); + } + } + public synchronized void requestNewAuthToken(boolean hasFailedPriorAuth, IterableHelper.SuccessHandler successCallback) { requestNewAuthToken(hasFailedPriorAuth, successCallback, true); } @@ -61,6 +152,9 @@ void reset() { void setIsLastAuthTokenValid(boolean isValid) { isLastAuthTokenValid = isValid; + if (isValid) { + setAuthState(AuthState.VALID); + } } void resetRetryCount() { @@ -126,13 +220,16 @@ public void run() { } } else { - IterableApi.getInstance().setAuthToken(null, true); + IterableApi.getInstance().completeUserLogin(); } } private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHandler successCallback) { if (authToken != null) { - IterableApi.getInstance().setAuthToken(authToken); + // Token obtained but not yet verified by a request - set state to UNKNOWN. + // setAuthState will notify listeners only if previous state was INVALID. + setAuthState(AuthState.UNKNOWN); + IterableApi.getInstance().updateAuthToken(authToken); queueExpirationRefresh(authToken); if (successCallback != null) { @@ -140,7 +237,7 @@ private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHand } } else { handleAuthFailure(authToken, AuthFailureReason.AUTH_TOKEN_NULL); - IterableApi.getInstance().setAuthToken(authToken); + IterableApi.getInstance().updateAuthToken(authToken); scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); return; } @@ -211,29 +308,31 @@ long getNextRetryInterval() { } void scheduleAuthTokenRefresh(long timeDuration, boolean isScheduledRefresh, final IterableHelper.SuccessHandler successCallback) { - if ((pauseAuthRetry && !isScheduledRefresh) || isTimerScheduled) { - // we only stop schedule token refresh if it is called from retry (in case of failure). The normal auth token refresh schedule would work - return; - } - if (timer == null) { - timer = new Timer(true); - } + synchronized (timerLock) { + if ((pauseAuthRetry && !isScheduledRefresh) || isTimerScheduled) { + // we only stop schedule token refresh if it is called from retry (in case of failure). The normal auth token refresh schedule would work + return; + } + if (timer == null) { + timer = new Timer(true); + } - try { - timer.schedule(new TimerTask() { - @Override - public void run() { - if (api.getEmail() != null || api.getUserId() != null) { - api.getAuthManager().requestNewAuthToken(false, successCallback, isScheduledRefresh); - } else { - IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh"); + try { + timer.schedule(new TimerTask() { + @Override + public void run() { + if (api.getEmail() != null || api.getUserId() != null) { + api.getAuthManager().requestNewAuthToken(false, successCallback, isScheduledRefresh); + } else { + IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh"); + } + isTimerScheduled = false; } - isTimerScheduled = false; - } - }, timeDuration); - isTimerScheduled = true; - } catch (Exception e) { - IterableLogger.e(TAG, "timer exception: " + timer, e); + }, timeDuration); + isTimerScheduled = true; + } catch (Exception e) { + IterableLogger.e(TAG, "timer exception: " + timer, e); + } } } @@ -282,10 +381,12 @@ private void checkAndHandleAuthRefresh() { } void clearRefreshTimer() { - if (timer != null) { - timer.cancel(); - timer = null; - isTimerScheduled = false; + synchronized (timerLock) { + if (timer != null) { + timer.cancel(); + timer = null; + isTimerScheduled = false; + } } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java index 85c4b7066..eb2d3fc4d 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java @@ -56,6 +56,7 @@ public final class IterableConstants { public static final String KEY_INBOX_SESSION_ID = "inboxSessionId"; public static final String KEY_EMBEDDED_SESSION_ID = "id"; public static final String KEY_OFFLINE_MODE = "offlineMode"; + public static final String KEY_AUTO_RETRY = "autoRetry"; public static final String KEY_FIRETV = "FireTV"; public static final String KEY_CREATE_NEW_FIELDS = "createNewFields"; public static final String KEY_IS_USER_KNOWN = "isUserKnown"; @@ -130,6 +131,7 @@ public final class IterableConstants { public static final String SHARED_PREFS_FCM_MIGRATION_DONE_KEY = "itbl_fcm_migration_done"; public static final String SHARED_PREFS_SAVED_CONFIGURATION = "itbl_saved_configuration"; public static final String SHARED_PREFS_OFFLINE_MODE_KEY = "itbl_offline_mode"; + public static final String SHARED_PREFS_AUTO_RETRY_KEY = "itbl_auto_retry"; public static final String SHARED_PREFS_EVENT_LIST_KEY = "itbl_event_list"; public static final String SHARED_PREFS_USER_UPDATE_OBJECT_KEY = "itbl_user_update_object"; public static final String SHARED_PREFS_UNKNOWN_SESSIONS = "itbl_unknown_sessions"; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index b28b54511..1e4ff3ab6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -14,6 +14,10 @@ class IterablePushNotificationUtil { private static PendingAction pendingAction = null; private static final String TAG = "IterablePushNotificationUtil"; + static void clearPendingAction() { + pendingAction = null; + } + static boolean processPendingAction(Context context) { boolean handled = false; if (pendingAction != null) { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index f052da780..08dfba9c2 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -18,6 +18,7 @@ import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; @@ -153,20 +154,27 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque // Read the response body try { BufferedReader in; - if (responseCode < 400) { + if (responseCode >= 0 && responseCode < 400) { in = new BufferedReader( new InputStreamReader(urlConnection.getInputStream())); } else { - in = new BufferedReader( - new InputStreamReader(urlConnection.getErrorStream())); + InputStream errorStream = urlConnection.getErrorStream(); + if (errorStream != null) { + in = new BufferedReader( + new InputStreamReader(errorStream)); + } else { + in = null; + } } - String inputLine; - StringBuffer response = new StringBuffer(); - while ((inputLine = in.readLine()) != null) { - response.append(inputLine); + if (in != null) { + String inputLine; + StringBuffer response = new StringBuffer(); + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + requestResult = response.toString(); } - in.close(); - requestResult = response.toString(); } catch (IOException e) { logError(iterableApiRequest, baseUrl, e); error = e.getMessage(); @@ -186,13 +194,20 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque jsonError = e.getMessage(); } + // If getResponseCode() returned -1 (e.g. due to network inspector + // interference) but the response body contains JWT error codes, + // we can infer the actual response was a 401. + if (responseCode == -1 && matchesJWTErrorCodes(jsonResponse)) { + responseCode = 401; + } + // Handle HTTP status codes if (responseCode == 401) { if (matchesJWTErrorCodes(jsonResponse)) { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "JWT Authorization header error"); IterableApi.getInstance().getAuthManager().handleAuthFailure(iterableApiRequest.authToken, getMappedErrorCodeForMessage(jsonResponse)); - // We handle the JWT Retry for both online and offline here rather than handling online request in onPostExecute - requestNewAuthTokenAndRetry(iterableApiRequest); + + handleJwtAuthRetry(iterableApiRequest); } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -246,6 +261,23 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque return apiResponse; } + /** + * When autoRetry is enabled and this is an offline task, do nothing here. + * IterableTaskRunner.processTask() is the sole owner of 401 handling for offline tasks: + * it calls setAuthTokenInvalid() which invalidates the token and schedules a refresh. + * When the new token arrives, onAuthTokenReady() resumes the queue. + * For online requests or when autoRetry is disabled, use the existing inline retry. + */ + private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) { + IterableLogger.d(TAG, "Offline task 401 - deferring retry to IterableTaskRunner"); + return; + } else { + requestNewAuthTokenAndRetry(iterableApiRequest); + } + } + private static String getBaseUrl() { IterableConfig config = IterableApi.getInstance().config; IterableDataRegion dataRegion = config.dataRegion; @@ -498,13 +530,27 @@ public JSONObject toJSONObject() throws JSONException { } static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + return fromJSON(jsonData, null, onSuccess, onFailure); + } + + /** + * Deserializes an IterableApiRequest from JSON. + * @param authTokenOverride If non-null, uses this token instead of the one stored in JSON. + * This allows offline tasks to use the latest auth token rather + * than the stale one captured at queue time. + */ + static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable String authTokenOverride, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { try { String apikey = jsonData.getString("apiKey"); String resourcePath = jsonData.getString("resourcePath"); String requestType = jsonData.getString("requestType"); - String authToken = ""; - if (jsonData.has("authToken")) { + String authToken; + if (authTokenOverride != null) { + authToken = authTokenOverride; + } else if (jsonData.has("authToken")) { authToken = jsonData.getString("authToken"); + } else { + authToken = ""; } JSONObject json = jsonData.getJSONObject("data"); return new IterableApiRequest(apikey, resourcePath, json, requestType, authToken, onSuccess, onFailure); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java index c886d7926..8ef02bb40 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java @@ -58,6 +58,10 @@ class IterableTask { this.taskType = taskType; } + boolean requiresJwt(ApiEndpointClassification classification) { + return classification.requiresJwt(this.name); + } + } enum IterableTaskType { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java index d27e7102d..96b25c9fd 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java @@ -14,12 +14,13 @@ import java.util.ArrayList; -class IterableTaskRunner implements IterableTaskStorage.TaskCreatedListener, Handler.Callback, IterableNetworkConnectivityManager.IterableNetworkMonitorListener, IterableActivityMonitor.AppStateCallback { +class IterableTaskRunner implements IterableTaskStorage.TaskCreatedListener, Handler.Callback, IterableNetworkConnectivityManager.IterableNetworkMonitorListener, IterableActivityMonitor.AppStateCallback, IterableAuthManager.AuthTokenReadyListener { private static final String TAG = "IterableTaskRunner"; private IterableTaskStorage taskStorage; private IterableActivityMonitor activityMonitor; private IterableNetworkConnectivityManager networkConnectivityManager; private HealthMonitor healthMonitor; + private ApiEndpointClassification classification; private static final int RETRY_INTERVAL_SECONDS = 60; @@ -39,14 +40,19 @@ interface TaskCompletedListener { private ArrayList taskCompletedListeners = new ArrayList<>(); + // Tracks whether processing is paused due to a JWT auth failure + private volatile boolean isPausedForAuth = false; + IterableTaskRunner(IterableTaskStorage taskStorage, IterableActivityMonitor activityMonitor, IterableNetworkConnectivityManager networkConnectivityManager, - HealthMonitor healthMonitor) { + HealthMonitor healthMonitor, + ApiEndpointClassification classification) { this.taskStorage = taskStorage; this.activityMonitor = activityMonitor; this.networkConnectivityManager = networkConnectivityManager; this.healthMonitor = healthMonitor; + this.classification = classification; networkThread.start(); handler = new Handler(networkThread.getLooper(), this); taskStorage.addTaskCreatedListener(this); @@ -54,6 +60,14 @@ interface TaskCompletedListener { activityMonitor.addCallback(this); } + // Preserved for backward compatibility with existing tests + IterableTaskRunner(IterableTaskStorage taskStorage, + IterableActivityMonitor activityMonitor, + IterableNetworkConnectivityManager networkConnectivityManager, + HealthMonitor healthMonitor) { + this(taskStorage, activityMonitor, networkConnectivityManager, healthMonitor, new ApiEndpointClassification()); + } + void addTaskCompletedListener(TaskCompletedListener listener) { taskCompletedListeners.add(listener); } @@ -87,6 +101,12 @@ public void onSwitchToBackground() { } + @Override + public void onAuthTokenReady() { + isPausedForAuth = false; + runNow(); + } + private synchronized void runNow() { handler.removeMessages(OPERATION_PROCESS_TASKS); handler.sendEmptyMessage(OPERATION_PROCESS_TASKS); @@ -118,28 +138,50 @@ private void processTasks() { return; } + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + while (networkConnectivityManager.isConnected()) { - IterableTask task = taskStorage.getNextScheduledTask(); + IterableTask task = getNextActionableTask(autoRetry); if (task == null) { return; } - boolean proceed = processTask(task); + boolean proceed = processTask(task, autoRetry); if (!proceed) { - scheduleRetry(); + // Only schedule timed retry for non-auth failures. + // Auth failures will resume via onAuthTokenReady() callback. + if (!autoRetry || !isPausedForAuth) { + scheduleRetry(); + } return; } } } + private IterableTask getNextActionableTask(boolean autoRetry) { + boolean authBlocked = isPausedForAuth || + (autoRetry && !IterableApi.getInstance().getAuthManager().isAuthTokenReady()); + if (!authBlocked) { + return taskStorage.getNextScheduledTask(); + } + return taskStorage.getNextScheduledTaskNotRequiringJwt(classification); + } + + void setIsPausedForAuth(boolean paused) { + this.isPausedForAuth = paused; + } + @WorkerThread - private boolean processTask(@NonNull IterableTask task) { + private boolean processTask(@NonNull IterableTask task, boolean autoRetry) { if (task.taskType == IterableTaskType.API) { IterableApiResponse response = null; TaskResult result = TaskResult.FAILURE; try { - IterableApiRequest request = IterableApiRequest.fromJSON(getTaskDataWithDate(task), null, null); + // Use the current live auth token instead of the stale one stored in the DB. + // The token in the DB was captured at queue time and may have since expired. + String currentAuthToken = IterableApi.getInstance().getAuthToken(); + IterableApiRequest request = IterableApiRequest.fromJSON(getTaskDataWithDate(task), currentAuthToken, null, null); request.setProcessorType(IterableApiRequest.ProcessorType.OFFLINE); response = IterableRequestTask.executeApiRequest(request); } catch (Exception e) { @@ -151,10 +193,20 @@ private boolean processTask(@NonNull IterableTask task) { if (response.success) { result = TaskResult.SUCCESS; } else { - if (isRetriableError(response.errorMessage)) { - result = TaskResult.RETRY; - } else { + // If autoRetry is enabled and response is a 401 JWT error, + // retain the task and pause processing until a valid JWT is obtained. + if (autoRetry && isJwtFailure(response)) { + IterableLogger.d(TAG, "JWT auth failure on task " + task.id + ". Retaining task and pausing processing."); + IterableApi.getInstance().getAuthManager().handleAuthTokenRejection(); + isPausedForAuth = true; + callTaskCompletedListeners(task.id, TaskResult.RETRY, response); + return false; + } + + if (isPermanentFailure(response)) { result = TaskResult.FAILURE; + } else { + result = TaskResult.RETRY; } } } @@ -181,8 +233,31 @@ JSONObject getTaskDataWithDate(IterableTask task) { return null; } - private boolean isRetriableError(String errorMessage) { - return errorMessage.contains("failed to connect"); + /** + * Returns true for permanent client errors that should NOT be retried. + * 4xx (except 401 JWT handled above, and 429 rate limit) are permanent. + * 5xx, network errors (responseCode 0), timeouts, and connection failures are transient. + */ + private boolean isPermanentFailure(IterableApiResponse response) { + int code = response.responseCode; + if (code == 0) { + // No HTTP status — network-level error (timeout, DNS, connection reset). Transient. + return false; + } + if (code == 429) { + // Rate limit — server asking us to retry later. Transient. + return false; + } + return code >= 400 && code < 500; + } + + /** + * Checks if the response indicates a JWT authentication failure (401). + * In the offline processing context, the API key is known to be valid (the task was + * queued with it), so any 401 response is a JWT auth error. + */ + private boolean isJwtFailure(IterableApiResponse response) { + return response.responseCode == 401; } @WorkerThread diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java index 0cce48b73..aa8f50211 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java @@ -287,6 +287,34 @@ IterableTask getNextScheduledTask() { return task; } + /** + * Returns the next scheduled task that does not require JWT authentication. + * Iterates tasks ordered by scheduledAt and returns the first one classified + * as unauthenticated by the given classification. + * + * @param classification the endpoint classification to check against + * @return next unauthenticated {@link IterableTask}, or null if none found + */ + @Nullable + IterableTask getNextScheduledTaskNotRequiringJwt(ApiEndpointClassification classification) { + if (!isDatabaseReady()) { + return null; + } + Cursor cursor = database.rawQuery("select * from OfflineTask order by scheduled", null); + IterableTask task = null; + if (cursor.moveToFirst()) { + do { + IterableTask candidate = createTaskFromCursor(cursor); + if (!candidate.requiresJwt(classification)) { + task = candidate; + break; + } + } while (cursor.moveToNext()); + } + cursor.close(); + return task; + } + /** * Deletes all the entries from the OfflineTask table. */ diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java index e60b08293..13a515660 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java @@ -30,19 +30,44 @@ class OfflineRequestProcessor implements RequestProcessor { IterableConstants.ENDPOINT_TRACK_INAPP_CLOSE, IterableConstants.ENDPOINT_TRACK_INBOX_SESSION, IterableConstants.ENDPOINT_TRACK_INAPP_DELIVERY, - IterableConstants.ENDPOINT_GET_EMBEDDED_MESSAGES, IterableConstants.ENDPOINT_INAPP_CONSUME, + IterableConstants.ENDPOINT_UPDATE_CART, + IterableConstants.ENDPOINT_TRACK_EMBEDDED_RECEIVED, + IterableConstants.ENDPOINT_TRACK_EMBEDDED_CLICK, + IterableConstants.ENDPOINT_TRACK_EMBEDDED_SESSION, IterableConstants.ENDPOINT_DISABLE_DEVICE)); OfflineRequestProcessor(Context context) { IterableNetworkConnectivityManager networkConnectivityManager = IterableNetworkConnectivityManager.sharedInstance(context); taskStorage = IterableTaskStorage.sharedInstance(context); healthMonitor = new HealthMonitor(taskStorage); + ApiEndpointClassification classification = IterableApi.getInstance().apiEndpointClassification; taskRunner = new IterableTaskRunner(taskStorage, IterableActivityMonitor.getInstance(), networkConnectivityManager, - healthMonitor); + healthMonitor, + classification); taskScheduler = new TaskScheduler(taskStorage, taskRunner); + + // Register task runner as auth token ready listener for JWT auto-retry support + try { + IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); + } catch (Exception e) { + IterableLogger.w("OfflineRequestProcessor", "Failed to register auth token listener. " + + "Auto-retry on JWT failure will not work until AuthManager is available."); + } + } + + /** + * Unregisters the auth token listener to prevent stale listener accumulation + * when the processor is replaced (e.g., when offline mode is toggled). + */ + void dispose() { + try { + IterableApi.getInstance().getAuthManager().removeAuthTokenReadyListener(taskRunner); + } catch (Exception e) { + IterableLogger.w("OfflineRequestProcessor", "Failed to unregister auth token listener on dispose."); + } } @VisibleForTesting diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java new file mode 100644 index 000000000..6036be5fd --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java @@ -0,0 +1,58 @@ +package com.iterable.iterableapi; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ApiEndpointClassificationTest { + + private ApiEndpointClassification classification; + + @Before + public void setUp() { + classification = new ApiEndpointClassification(); + } + + @Test + public void testDefaultUnauthenticatedEndpoints() { + // THIS IS IMPORTANT SO IF WE CHANGE IT FOR TESTING WE WILL HAVE THIS FAILING + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_DISABLE_DEVICE)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_MERGE_USER)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_CRITERIA_LIST)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_TRACK_UNKNOWN_SESSION)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_TRACK_CONSENT)); + } + + @Test + public void testUnknownEndpointRequiresJwt() { + assertTrue(classification.requiresJwt("unknown/endpoint")); + } + + @Test + public void testUpdateFromRemoteConfigOverridesDefaults() { + // Override: now only "events/track" is unauthenticated + classification.updateFromRemoteConfig( + new HashSet<>(Arrays.asList(IterableConstants.ENDPOINT_TRACK)) + ); + + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_TRACK)); + // Previously unauthenticated endpoints now require JWT + assertTrue(classification.requiresJwt(IterableConstants.ENDPOINT_DISABLE_DEVICE)); + assertTrue(classification.requiresJwt(IterableConstants.ENDPOINT_MERGE_USER)); + } + + @Test + public void testIterableTaskRequiresJwtDelegation() { + IterableTask authTask = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, "{}"); + IterableTask unauthTask = new IterableTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, "{}"); + + assertTrue(authTask.requiresJwt(classification)); + assertFalse(unauthTask.requiresJwt(classification)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java index 27f8f44ae..9f66e61f6 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java @@ -6,6 +6,7 @@ import com.iterable.iterableapi.unit.TestRunner; +import org.junit.After; import org.junit.Rule; import org.junit.rules.TestWatcher; import org.junit.runner.Description; @@ -22,6 +23,14 @@ public abstract class BaseTest { @Rule public AsyncTaskRule asyncTaskRule = new AsyncTaskRule(); + @After + public void baseTestTearDown() { + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + IterablePushNotificationUtil.clearPendingAction(); + IterableApi.sharedInstance = new IterableApi(); + } + protected IterableUtilImpl getIterableUtilSpy() { return utilsRule.iterableUtilSpy; } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java index 05f4be969..d772dc3c0 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java @@ -2,7 +2,6 @@ import android.app.Activity; -import org.junit.After; import org.junit.Before; import org.junit.Test; import org.robolectric.Robolectric; @@ -22,12 +21,6 @@ public void setUp() { IterableActivityMonitor.getInstance().registerLifecycleCallbacks(getContext()); } - @After - public void tearDown() { - IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); - IterableActivityMonitor.instance = new IterableActivityMonitor(); - } - @Test public void testOneActivityStarted() { Robolectric.buildActivity(Activity.class).create().start().resume(); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java index 2fa168b59..29c5a5300 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java @@ -99,13 +99,13 @@ public void testCompleteUserLogin_WithJWTAuth_NoToken_SkipsSensitiveOps() throws when(api.getInAppManager()).thenReturn(mockInAppManager); when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); - // Directly call setAuthToken with null and bypassAuth=true to simulate + // Directly call updateAuthToken with null to simulate // attempting to bypass with no token (user-controlled bypass scenario) - api.setAuthToken(null, true); + api.updateAuthToken(null); shadowOf(getMainLooper()).idle(); - // Verify sensitive operations were NOT called (JWT auth enabled, no token) + // Verify sensitive operations were NOT called (updateAuthToken only stores, no login side effects) verify(mockInAppManager, never()).syncInApp(); verify(mockEmbeddedManager, never()).syncMessages(); } @@ -141,6 +141,53 @@ public void testCompleteUserLogin_WithJWTAuth_WithToken_ExecutesSensitiveOps() t verify(mockEmbeddedManager).syncMessages(); } + /** + * Regression test: calling setEmail with the same email that's already set (e.g. after app + * restart where email is restored from keychain) should still trigger the full login flow + * (request auth token, syncInApp, syncMessages). + * + * Previously, the same-email path called checkAndUpdateAuthToken(null) which did nothing, + * so no login side effects occurred. + */ + @Test + public void testSetEmail_SameEmail_StillTriggersLogin() throws Exception { + initIterableWithAuth(); + + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + // First login — triggers full flow + api.setEmail("user@example.com"); + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + + verify(mockInAppManager).syncInApp(); + verify(mockEmbeddedManager).syncMessages(); + + // Clear invocations so we can verify the second call independently + org.mockito.Mockito.clearInvocations(mockInAppManager, mockEmbeddedManager); + + // Enqueue another response for the second login's /users/update call + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + + // Second login with SAME email — simulates app restart where email is in keychain + api.setEmail("user@example.com"); + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + + // This SHOULD still trigger login side effects + verify(mockInAppManager).syncInApp(); + verify(mockEmbeddedManager).syncMessages(); + } + /** * Test that completeUserLogin executes sensitive operations when JWT auth is NOT enabled, * even without an authToken. @@ -246,14 +293,16 @@ public void testSetAuthToken_UsesCompletionHandlerPattern() throws Exception { org.mockito.Mockito.clearInvocations(mockInAppManager, mockEmbeddedManager); // Now update auth token (simulating token refresh) + // updateAuthToken just stores the token — it does not trigger completeUserLogin. + // Sensitive operations (syncInApp, syncMessages) are only triggered during login flow. final String newToken = "new_jwt_token_here"; - api.setAuthToken(newToken, false); + api.updateAuthToken(newToken); shadowOf(getMainLooper()).idle(); - // Verify sensitive operations were called with updated token - verify(mockInAppManager).syncInApp(); - verify(mockEmbeddedManager).syncMessages(); + // Verify sensitive operations were NOT called (updateAuthToken only stores, doesn't trigger login) + verify(mockInAppManager, never()).syncInApp(); + verify(mockEmbeddedManager, never()).syncMessages(); assertEquals("Token should be updated", newToken, api.getAuthToken()); } @@ -274,7 +323,7 @@ public void testSetAuthToken_BypassAuth_StillValidatesToken() throws Exception { when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); // Try to bypass with no token set - api.setAuthToken(null, true); + api.updateAuthToken(null); shadowOf(getMainLooper()).idle(); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java index 12d1b473b..912eb1cf3 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java @@ -1,5 +1,7 @@ package com.iterable.iterableapi; +import android.content.Context; + import com.iterable.iterableapi.unit.PathBasedQueueDispatcher; import org.junit.After; @@ -23,9 +25,14 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.robolectric.Shadows.shadowOf; + +import org.mockito.Mockito; import static org.robolectric.annotation.LooperMode.Mode.PAUSED; @LooperMode(PAUSED) @@ -41,6 +48,7 @@ public class IterableApiAuthTests extends BaseTest { @Before public void setUp() { + getContext().getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE).edit().clear().apply(); server = new MockWebServer(); dispatcher = new PathBasedQueueDispatcher(); @@ -509,4 +517,54 @@ public void testAuthTokenRefreshPausesOnBackground() throws Exception { // Test passes if no exceptions were thrown and lifecycle methods executed successfully } + @Test + public void testTokenRefreshDoesNotTriggerPushRegistration() throws Exception { + IterablePushRegistration.IterablePushRegistrationImpl originalPushImpl = IterablePushRegistration.instance; + IterablePushRegistration.instance = mock(IterablePushRegistration.IterablePushRegistrationImpl.class); + + try { + // Initialize with auth and auto push registration enabled + // Clear keychain data from setUp so retrieveEmailAndUserId starts fresh + getContext().getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE).edit().clear().apply(); + IterableApi.sharedInstance = new IterableApi(); + authHandler = mock(IterableAuthHandler.class); + IterableApi.initialize(getContext(), "apiKey", + new IterableConfig.Builder() + .setAuthHandler(authHandler) + .setAutoPushRegistration(true) + .setPushIntegrationName("pushIntegration") + .build()); + + // Initial login: setEmail triggers requestNewAuthToken on executor thread + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + IterableApi.getInstance().setEmail("test@example.com"); + // Allow executor thread to complete and main looper to process callbacks + Thread.sleep(500); + shadowOf(getMainLooper()).idle(); + shadowOf(getMainLooper()).runToEndOfTasks(); + + // Verify initial push registration happened + verify(IterablePushRegistration.instance).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + + // Reset mock to clear invocation history + Mockito.reset(IterablePushRegistration.instance); + + // Trigger auth token refresh with a different JWT + doReturn(newJWT).when(authHandler).onAuthTokenRequested(); + IterableApi.getInstance().getAuthManager().requestNewAuthToken(false, null); + // Allow executor thread to complete and main looper to process callbacks + Thread.sleep(500); + shadowOf(getMainLooper()).idle(); + shadowOf(getMainLooper()).runToEndOfTasks(); + + // Verify the token was actually refreshed + assertEquals(newJWT, IterableApi.getInstance().getAuthToken()); + + // Assert that push registration was NOT called again on token refresh + verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + } finally { + IterablePushRegistration.instance = originalPushImpl; + } + } + } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java index b128af40c..8d873993a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java @@ -143,8 +143,8 @@ public void testAttributionInfoPersistence() throws Exception { assertEquals(attributionInfo.templateId, storedAttributionInfo.templateId); assertEquals(attributionInfo.messageId, storedAttributionInfo.messageId); - // 24 hours, expired, attributionInfo should be null - doReturn(System.currentTimeMillis() + 3600 * 24 * 1000).when(utilsRule.iterableUtilSpy).currentTimeMillis(); + // Just past 24 hours, expired, attributionInfo should be null + doReturn(System.currentTimeMillis() + 3600 * 24 * 1000 + 1).when(utilsRule.iterableUtilSpy).currentTimeMillis(); storedAttributionInfo = IterableApi.getInstance().getAttributionInfo(); assertNull(storedAttributionInfo); } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java index e18614061..7dcabe729 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java @@ -88,8 +88,6 @@ public IterableConfig.Builder run(IterableConfig.Builder builder) { public void tearDown() throws IOException { server.shutdown(); server = null; - IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); - IterableActivityMonitor.instance = new IterableActivityMonitor(); } @Ignore("Ignoring due to stalling") diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java index b9145748d..5559ba72a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java @@ -1,5 +1,9 @@ package com.iterable.iterableapi; +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + import com.iterable.iterableapi.unit.TestRunner; import org.json.JSONObject; @@ -16,12 +20,16 @@ import static android.os.Looper.getMainLooper; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -29,7 +37,7 @@ import static org.robolectric.Shadows.shadowOf; @RunWith(TestRunner.class) -public class IterableTaskRunnerTest { +public class IterableTaskRunnerTest extends BaseTest { private IterableTaskRunner taskRunner; private IterableTaskStorage mockTaskStorage; private IterableActivityMonitor mockActivityMonitor; @@ -51,6 +59,7 @@ public void setUp() throws Exception { @After public void tearDown() throws Exception { server.shutdown(); + IterableTestUtils.resetIterableApi(); } @Test @@ -62,6 +71,7 @@ public void testRunOnTaskCreatedMakesApiRequest() throws Exception { when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); when(mockHealthMonitor.canSchedule()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); @@ -154,6 +164,7 @@ public void testIfNetworkCheckedBeforeProcessingTask() throws Exception { when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); when(mockHealthMonitor.canSchedule()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); @@ -161,6 +172,462 @@ public void testIfNetworkCheckedBeforeProcessingTask() throws Exception { verify(mockNetworkConnectivityManager, times(2)).isConnected(); } + // region Auto-Retry on JWT Failure Tests + + private String createJwt401ResponseBody() throws Exception { + JSONObject body = new JSONObject(); + body.put("code", "InvalidJwtPayload"); + body.put("msg", "jwt token is expired"); + return body.toString(); + } + + private IterableAuthHandler initApiWithAutoRetry(boolean autoRetryEnabled) { + IterableApi.sharedInstance = new IterableApi(); + final IterableAuthHandler mockAuthHandler = mock(IterableAuthHandler.class); + doReturn(null).when(mockAuthHandler).onAuthTokenRequested(); + + Context context = ApplicationProvider.getApplicationContext(); + context.getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE) + .edit() + .putBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, autoRetryEnabled) + .apply(); + + // Initialize directly without calling setEmail to avoid triggering an async + // auth flow. The null token from the mock handler would race with the test, + // and the resulting syncInApp() call would send unexpected requests to the + // mock server, breaking assertions that check for no requests. + IterableConfig config = new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setAuthHandler(mockAuthHandler) + .build(); + IterableApi.initialize(context, IterableTestUtils.apiKey, config); + return mockAuthHandler; + } + + @Test + public void testAutoRetryEnabled_JwtFailure_TaskRetainedInDB() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "expired_token", null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + + IterableTaskRunner.TaskCompletedListener taskCompletedListener = mock(IterableTaskRunner.TaskCompletedListener.class); + taskRunner.addTaskCompletedListener(taskCompletedListener); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + shadowOf(getMainLooper()).idle(); + verify(taskCompletedListener).onTaskCompleted(any(String.class), eq(IterableTaskRunner.TaskResult.RETRY), any(IterableApiResponse.class)); + + assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); + } + + @Test + public void testAutoRetryDisabled_JwtFailure_TaskDeletedFromDB() throws Exception { + initApiWithAutoRetry(false); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "expired_token", null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_ProcessingPausesWhenAuthInvalid() throws Exception { + initApiWithAutoRetry(true); + + IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_ProcessingResumesOnAuthTokenReady() throws Exception { + initApiWithAutoRetry(true); + + IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + + IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); + + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); + runHandlerTasks(taskRunner); + + recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/api/test", recordedRequest.getPath()); + + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_SuccessfulRequest_TaskDeleted() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableTaskRunner.TaskCompletedListener taskCompletedListener = mock(IterableTaskRunner.TaskCompletedListener.class); + taskRunner.addTaskCompletedListener(taskCompletedListener); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + verify(mockTaskStorage).deleteTask(any(String.class)); + + shadowOf(getMainLooper()).idle(); + verify(taskCompletedListener).onTaskCompleted(any(String.class), eq(IterableTaskRunner.TaskResult.SUCCESS), any(IterableApiResponse.class)); + } + + @Test + public void testAutoRetryEnabled_Any401_TaskRetainedInDB() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + JSONObject body401 = new JSONObject(); + body401.put("code", "InvalidApiKey"); + body401.put("msg", "Invalid API key"); + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(body401.toString())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_Non401Error_TaskDeletedNormally() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + JSONObject body400 = new JSONObject(); + body400.put("msg", "Bad request"); + server.enqueue(new MockResponse() + .setResponseCode(400) + .setBody(body400.toString())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAuthManagerListenerRegistration() { + initApiWithAutoRetry(true); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + authManager.addAuthTokenReadyListener(taskRunner); + + assertTrue(authManager.isAuthTokenReady()); + + authManager.setAuthTokenInvalid(); + assertFalse(authManager.isAuthTokenReady()); + assertEquals(IterableAuthManager.AuthState.INVALID, authManager.getAuthState()); + + authManager.setIsLastAuthTokenValid(true); + assertTrue(authManager.isAuthTokenReady()); + assertEquals(IterableAuthManager.AuthState.VALID, authManager.getAuthState()); + } + + @Test + public void testAutoRetryEnabled_UsesCurrentLiveAuthToken() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "stale_token_from_db", null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + JSONObject taskJson = request.toJSONObject(); + IterableApiRequest deserializedRequest = IterableApiRequest.fromJSON(taskJson, "fresh_live_token", null, null); + assertEquals("fromJSON should use authTokenOverride instead of stored token", + "fresh_live_token", deserializedRequest.authToken); + + IterableApiRequest deserializedWithoutOverride = IterableApiRequest.fromJSON(taskJson, null, null, null); + assertEquals("fromJSON without override should use stored token", + "stale_token_from_db", deserializedWithoutOverride.authToken); + } + + @Test + public void testAutoRetryEnabled_MultipleTasksInQueue_PausesAfterFirstJwtFailure() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request1 = new IterableApiRequest("apiKey", "api/test1", new JSONObject(), "POST", null, null, null); + IterableApiRequest request2 = new IterableApiRequest("apiKey", "api/test2", new JSONObject(), "POST", null, null, null); + IterableApiRequest request3 = new IterableApiRequest("apiKey", "api/test3", new JSONObject(), "POST", null, null, null); + + IterableTask task1 = new IterableTask("task1", IterableTaskType.API, request1.toJSONObject().toString()); + IterableTask task2 = new IterableTask("task2", IterableTaskType.API, request2.toJSONObject().toString()); + IterableTask task3 = new IterableTask("task3", IterableTaskType.API, request3.toJSONObject().toString()); + + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task1).thenReturn(task2).thenReturn(task3).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest1 = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest1); + assertEquals("/api/test1", recordedRequest1.getPath()); + + RecordedRequest recordedRequest2 = server.takeRequest(1, TimeUnit.SECONDS); + assertNull("Processing should pause after first JWT failure", recordedRequest2); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); + } + + @Test + public void testAuthTokenReadyListener_NotifiedOnStateTransitionFromInvalid() { + initApiWithAutoRetry(true); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + IterableAuthManager.AuthTokenReadyListener mockListener = mock(IterableAuthManager.AuthTokenReadyListener.class); + authManager.addAuthTokenReadyListener(mockListener); + + authManager.setAuthTokenInvalid(); + verify(mockListener, never()).onAuthTokenReady(); + + authManager.setIsLastAuthTokenValid(true); + verify(mockListener, times(1)).onAuthTokenReady(); + + authManager.setAuthTokenInvalid(); + verify(mockListener, times(1)).onAuthTokenReady(); + + authManager.setAuthTokenInvalid(); + verify(mockListener, times(1)).onAuthTokenReady(); + + authManager.setIsLastAuthTokenValid(true); + verify(mockListener, times(2)).onAuthTokenReady(); + + authManager.removeAuthTokenReadyListener(mockListener); + } + + // endregion + + // region Unauthenticated API Bypass Tests + + @Test + public void testUnauthenticatedTaskExecutesDuringAuthPause() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + IterableApiRequest request = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_DISABLE_DEVICE, new JSONObject(), "POST", null, null, null); + IterableTask unauthTask = new IterableTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, request.toJSONObject().toString()); + + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(unauthTask).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, recordedRequest.getPath()); + verify(mockTaskStorage).deleteTask(unauthTask.id); + } + + @Test + public void testAuthRequiredTaskStaysBlockedDuringAuthPause() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testQueueIntegrityAfterAuthPausedProcessing() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + IterableApiRequest trackRequestA = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject("{\"eventName\":\"A\"}"), "POST", null, null, null); + IterableTask trackTaskA = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequestA.toJSONObject().toString()); + + IterableApiRequest disableRequest = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_DISABLE_DEVICE, new JSONObject(), "POST", null, null, null); + IterableTask disableTask = new IterableTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, disableRequest.toJSONObject().toString()); + + IterableApiRequest trackRequestB = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject("{\"eventName\":\"B\"}"), "POST", null, null, null); + IterableTask trackTaskB = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequestB.toJSONObject().toString()); + + IterableApiRequest trackRequestC = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject("{\"eventName\":\"C\"}"), "POST", null, null, null); + IterableTask trackTaskC = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequestC.toJSONObject().toString()); + + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(disableTask).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + verify(mockTaskStorage).deleteTask(disableTask.id); + verify(mockTaskStorage, never()).deleteTask(trackTaskA.id); + verify(mockTaskStorage, never()).deleteTask(trackTaskB.id); + verify(mockTaskStorage, never()).deleteTask(trackTaskC.id); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, recordedRequest.getPath()); + + RecordedRequest secondRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(secondRequest); + } + + @Test + public void testAuthRequiredTasksResumeAfterAuthReady() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + IterableApiRequest trackRequest = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject(), "POST", null, null, null); + IterableTask trackTask = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequest.toJSONObject().toString()); + + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Phase 1: Auth paused, no unauthenticated tasks available + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(null); + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + // Phase 2: Auth ready, track task should now process + when(mockTaskStorage.getNextScheduledTask()).thenReturn(trackTask).thenReturn(null); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + runner.setIsPausedForAuth(false); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/" + IterableConstants.ENDPOINT_TRACK, recordedRequest.getPath()); + verify(mockTaskStorage).deleteTask(trackTask.id); + } + + // endregion + private void runHandlerTasks(IterableTaskRunner taskRunner) throws InterruptedException { shadowOf(taskRunner.handler.getLooper()).idle(); }