diff --git a/example-apps/javademoapp/src/main/java/com/stytch/javademoapp/MainViewModel.java b/example-apps/javademoapp/src/main/java/com/stytch/javademoapp/MainViewModel.java index 8e06236dd..16ee03fd6 100644 --- a/example-apps/javademoapp/src/main/java/com/stytch/javademoapp/MainViewModel.java +++ b/example-apps/javademoapp/src/main/java/com/stytch/javademoapp/MainViewModel.java @@ -3,6 +3,7 @@ import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; +import com.stytch.sdk.common.StytchObjectInfo; import com.stytch.sdk.common.StytchResult; import com.stytch.sdk.common.network.models.BasicData; import com.stytch.sdk.consumer.StytchClient; @@ -36,7 +37,11 @@ Unit handleInitializationChange(Boolean isInitialized) { return Unit.INSTANCE; } - private Unit handleUserChange(UserData userData) { + private Unit handleUserChange(StytchObjectInfo stytchUser) { + UserData userData = null; + if (stytchUser instanceof StytchObjectInfo.Available) { + userData = ((StytchObjectInfo.Available) stytchUser).getValue(); + } StytchState newState = new StytchState( true, StytchClient.getSessions().getSync(), @@ -46,7 +51,11 @@ private Unit handleUserChange(UserData userData) { return Unit.INSTANCE; } - private Unit handleSessionChange(SessionData sessionData) { + private Unit handleSessionChange(StytchObjectInfo stytchSession) { + SessionData sessionData = null; + if (stytchSession instanceof StytchObjectInfo.Available) { + sessionData = ((StytchObjectInfo.Available) stytchSession).getValue(); + } StytchState newState = new StytchState( true, sessionData, diff --git a/example-apps/stytchexampleapp/src/main/java/com/stytch/stytchexampleapp/MainViewModel.kt b/example-apps/stytchexampleapp/src/main/java/com/stytch/stytchexampleapp/MainViewModel.kt index 77fea8979..990411b6d 100644 --- a/example-apps/stytchexampleapp/src/main/java/com/stytch/stytchexampleapp/MainViewModel.kt +++ b/example-apps/stytchexampleapp/src/main/java/com/stytch/stytchexampleapp/MainViewModel.kt @@ -3,27 +3,51 @@ package com.stytch.stytchexampleapp import android.os.Parcelable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.consumer.StytchClient import com.stytch.sdk.consumer.network.models.SessionData import com.stytch.sdk.consumer.network.models.UserData +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize class MainViewModel : ViewModel() { - val authenticationState = - combine( - StytchClient.isInitialized, - StytchClient.user.onChange, - StytchClient.sessions.onChange, - ) { isInitialized, userData, sessionData -> - AuthenticationState( - isInitialized = isInitialized, - userData = userData, - sessionData = sessionData, - ) - }.stateIn(viewModelScope, SharingStarted.Lazily, AuthenticationState()) + private var _authenticationState = MutableStateFlow(AuthenticationState()) + var authenticationState: StateFlow = _authenticationState.asStateFlow() + + init { + viewModelScope.launch { + authenticationState = + combine( + StytchClient.isInitialized, + StytchClient.user.onChange, + StytchClient.sessions.onChange, + ) { isInitialized, stytchUser, stytchSession -> + val userData = + if (stytchUser is StytchObjectInfo.Available) { + stytchUser.value + } else { + null + } + val sessionData = + if (stytchSession is StytchObjectInfo.Available) { + stytchSession.value + } else { + null + } + AuthenticationState( + isInitialized = isInitialized, + userData = userData, + sessionData = sessionData, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, AuthenticationState()) + } + } } @Parcelize diff --git a/source/sdk/build.gradle b/source/sdk/build.gradle index b9e291334..383e72efc 100644 --- a/source/sdk/build.gradle +++ b/source/sdk/build.gradle @@ -10,7 +10,7 @@ plugins { ext { PUBLISH_GROUP_ID = 'com.stytch.sdk' - PUBLISH_VERSION = '0.28.0' + PUBLISH_VERSION = '0.29.0' PUBLISH_ARTIFACT_ID = 'sdk' } diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt index f3692d255..30e32f5e1 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt @@ -150,8 +150,12 @@ public object StytchB2BClient { ) // if there are session identifiers on device start the auto updater to ensure it is still valid if (sessionStorage.persistedSessionIdentifiersExist) { - StytchB2BApi.Sessions.authenticate(null).apply { - launchSessionUpdater(dispatchers, sessionStorage) + sessionStorage.memberSession?.let { + // if we have a session, it's expiration date has already been validated, now attempt + // to validate it with the Stytch servers + StytchB2BApi.Sessions.authenticate(null).apply { + launchSessionUpdater(dispatchers, sessionStorage) + } } } _isInitialized.value = true diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/member/Member.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/member/Member.kt index 89f13a3d2..741de5193 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/member/Member.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/member/Member.kt @@ -5,6 +5,7 @@ import com.stytch.sdk.b2b.MemberResponse import com.stytch.sdk.b2b.UpdateMemberResponse import com.stytch.sdk.b2b.network.models.MemberData import com.stytch.sdk.b2b.network.models.MfaMethod +import com.stytch.sdk.common.StytchObjectInfo import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.CompletableFuture @@ -15,13 +16,13 @@ public interface Member { /** * Exposes a flow of member data */ - public val onChange: StateFlow + public val onChange: StateFlow> /** * Assign a callback that will be called when the member data changes */ - public fun onChange(callback: (MemberData?) -> Unit) + public fun onChange(callback: (StytchObjectInfo) -> Unit) /** * Wraps Stytch’s organization/members/me endpoint. diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/member/MemberImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/member/MemberImpl.kt index 9ab0c7f4f..5f5f2f072 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/member/MemberImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/member/MemberImpl.kt @@ -7,10 +7,15 @@ import com.stytch.sdk.b2b.network.StytchB2BApi import com.stytch.sdk.b2b.network.models.MemberData import com.stytch.sdk.b2b.sessions.B2BSessionStorage import com.stytch.sdk.common.StytchDispatchers +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.StytchResult +import com.stytch.sdk.common.stytchObjectMapper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -22,9 +27,15 @@ internal class MemberImpl( private val sessionStorage: B2BSessionStorage, private val api: StytchB2BApi.Member, ) : Member { - private val callbacks = mutableListOf<(MemberData?) -> Unit>() + private val callbacks = mutableListOf<(StytchObjectInfo) -> Unit>() - override val onChange: StateFlow = sessionStorage.memberFlow + override val onChange: StateFlow> = + combine(sessionStorage.memberFlow, sessionStorage.lastValidatedAtFlow, ::stytchObjectMapper) + .stateIn( + externalScope, + SharingStarted.WhileSubscribed(), + stytchObjectMapper(sessionStorage.member, sessionStorage.lastValidatedAt), + ) init { externalScope.launch { @@ -36,7 +47,7 @@ internal class MemberImpl( } } - override fun onChange(callback: (MemberData?) -> Unit) { + override fun onChange(callback: (StytchObjectInfo) -> Unit) { callbacks.add(callback) } diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/Organization.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/Organization.kt index 1e85db585..9c5969424 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/Organization.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/Organization.kt @@ -21,6 +21,7 @@ import com.stytch.sdk.b2b.network.models.MfaPolicy import com.stytch.sdk.b2b.network.models.OrganizationData import com.stytch.sdk.b2b.network.models.SearchOperator import com.stytch.sdk.b2b.network.models.SsoJitProvisioning +import com.stytch.sdk.common.StytchObjectInfo import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.CompletableFuture @@ -32,13 +33,13 @@ public interface Organization { /** * Exposes a flow of organization data */ - public val onChange: StateFlow + public val onChange: StateFlow> /** * Assign a callback that will be called when the organization data changes */ - public fun onChange(callback: (OrganizationData?) -> Unit) + public fun onChange(callback: (StytchObjectInfo) -> Unit) /** * Wraps Stytch’s organization/me endpoint. diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/OrganizationImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/OrganizationImpl.kt index 9d362aa31..93a692e57 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/OrganizationImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/organization/OrganizationImpl.kt @@ -15,10 +15,15 @@ import com.stytch.sdk.b2b.network.models.B2BRequests import com.stytch.sdk.b2b.network.models.OrganizationData import com.stytch.sdk.b2b.sessions.B2BSessionStorage import com.stytch.sdk.common.StytchDispatchers +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.StytchResult +import com.stytch.sdk.common.stytchObjectMapper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -30,9 +35,15 @@ internal class OrganizationImpl( private val sessionStorage: B2BSessionStorage, private val api: StytchB2BApi.Organization, ) : Organization { - private val callbacks = mutableListOf<(OrganizationData?) -> Unit>() + private val callbacks = mutableListOf<(StytchObjectInfo) -> Unit>() - override val onChange: StateFlow = sessionStorage.organizationFlow + override val onChange: StateFlow> = + combine(sessionStorage.organizationFlow, sessionStorage.lastValidatedAtFlow, ::stytchObjectMapper) + .stateIn( + externalScope, + SharingStarted.WhileSubscribed(), + stytchObjectMapper(sessionStorage.organization, sessionStorage.lastValidatedAt), + ) init { externalScope.launch { @@ -44,7 +55,7 @@ internal class OrganizationImpl( } } - override fun onChange(callback: (OrganizationData?) -> Unit) { + override fun onChange(callback: (StytchObjectInfo) -> Unit) { callbacks.add(callback) } diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt index 3b9a8b47e..38c8408cd 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt @@ -1,14 +1,23 @@ package com.stytch.sdk.b2b.sessions +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import com.stytch.sdk.b2b.network.models.B2BSessionData import com.stytch.sdk.b2b.network.models.MemberData import com.stytch.sdk.b2b.network.models.OrganizationData import com.stytch.sdk.common.IST_EXPIRATION_TIME import com.stytch.sdk.common.PREFERENCES_NAME_IST import com.stytch.sdk.common.PREFERENCES_NAME_IST_EXPIRATION +import com.stytch.sdk.common.PREFERENCES_NAME_LAST_VALIDATED_AT +import com.stytch.sdk.common.PREFERENCES_NAME_MEMBER_DATA +import com.stytch.sdk.common.PREFERENCES_NAME_MEMBER_SESSION_DATA +import com.stytch.sdk.common.PREFERENCES_NAME_ORGANIZATION_DATA import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_JWT import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_TOKEN import com.stytch.sdk.common.StorageHelper +import com.stytch.sdk.common.StytchLog +import com.stytch.sdk.common.utils.getDateOrMin import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,6 +28,11 @@ internal class B2BSessionStorage( private val storageHelper: StorageHelper, private val externalScope: CoroutineScope, ) { + private val moshi = Moshi.Builder().build() + private val moshiB2BSessionDataAdapter = moshi.adapter(B2BSessionData::class.java).lenient() + private val moshiMemberDataAdapter = moshi.adapter(MemberData::class.java).lenient() + private val moshiOrganizationDataAdapter = moshi.adapter(OrganizationData::class.java).lenient() + var sessionToken: String? private set(value) { storageHelper.saveValue(PREFERENCES_NAME_SESSION_TOKEN, value) @@ -42,6 +56,7 @@ internal class B2BSessionStorage( } return value } + var intermediateSessionToken: String? private set(value) { storageHelper.saveValue(PREFERENCES_NAME_IST, value) @@ -81,27 +96,111 @@ internal class B2BSessionStorage( return value } - var memberSession: B2BSessionData? = null - internal set(value) { - field = value + var lastValidatedAt: Date + get() { + val longValue: Long? + synchronized(this) { + longValue = storageHelper.getLong(PREFERENCES_NAME_LAST_VALIDATED_AT) + } + return longValue?.let { Date(it) } ?: Date(0L) + } + private set(value) { + storageHelper.saveLong(PREFERENCES_NAME_LAST_VALIDATED_AT, value.time) externalScope.launch { - _sessionFlow.emit(field) + _lastValidatedAtFlow.emit(value) } } - var member: MemberData? = null + var memberSession: B2BSessionData? + get() { + val stringValue: String? + synchronized(this) { + stringValue = storageHelper.loadValue(PREFERENCES_NAME_MEMBER_SESSION_DATA) + } + return stringValue?.let { + // convert it back to a data class, check the expiration date, expire it if expired + val memberSessionData = + try { + moshiB2BSessionDataAdapter.fromJson(it) + } catch (e: JsonDataException) { + StytchLog.e(e.message ?: "Error parsing persisted B2BSessionData") + null + } + val expirationDate = memberSessionData?.expiresAt.getDateOrMin() + val now = Date() + if (expirationDate.before(now)) { + revoke() + return null + } + return memberSessionData + } + } + private set(value) { + value?.let { + val stringValue = moshiB2BSessionDataAdapter.toJson(it) + storageHelper.saveValue(PREFERENCES_NAME_MEMBER_SESSION_DATA, stringValue) + } ?: run { + storageHelper.saveValue(PREFERENCES_NAME_MEMBER_SESSION_DATA, null) + } + lastValidatedAt = Date() + externalScope.launch { + _sessionFlow.emit(value) + } + } + + var member: MemberData? + get() { + val stringValue: String? + synchronized(this) { + stringValue = storageHelper.loadValue(PREFERENCES_NAME_MEMBER_DATA) + } + return stringValue?.let { + try { + moshiMemberDataAdapter.fromJson(it) + } catch (e: JsonDataException) { + StytchLog.e(e.message ?: "Error parsing persisted MemberData") + null + } + } + } internal set(value) { - field = value + value?.let { + val stringValue = moshiMemberDataAdapter.toJson(it) + storageHelper.saveValue(PREFERENCES_NAME_MEMBER_DATA, stringValue) + } ?: run { + storageHelper.saveValue(PREFERENCES_NAME_MEMBER_DATA, null) + } + lastValidatedAt = Date() externalScope.launch { - _memberFlow.emit(field) + _memberFlow.emit(value) } } - var organization: OrganizationData? = null + var organization: OrganizationData? + get() { + val stringValue: String? + synchronized(this) { + stringValue = storageHelper.loadValue(PREFERENCES_NAME_ORGANIZATION_DATA) + } + return stringValue?.let { + try { + moshiOrganizationDataAdapter.fromJson(it) + } catch (e: JsonDataException) { + StytchLog.e(e.message ?: "Error parsing persisted OrganizationData") + null + } + } + } internal set(value) { - field = value + value?.let { + val stringValue = moshiOrganizationDataAdapter.toJson(it) + storageHelper.saveValue(PREFERENCES_NAME_ORGANIZATION_DATA, stringValue) + } ?: run { + storageHelper.saveValue(PREFERENCES_NAME_ORGANIZATION_DATA, null) + } + lastValidatedAt = Date() externalScope.launch { - _organizationFlow.emit(field) + _organizationFlow.emit(value) } } @@ -117,6 +216,9 @@ internal class B2BSessionStorage( private val _organizationFlow = MutableStateFlow(organization) val organizationFlow = _organizationFlow.asStateFlow() + private val _lastValidatedAtFlow = MutableStateFlow(lastValidatedAt) + val lastValidatedAtFlow = _lastValidatedAtFlow.asStateFlow() + /** * @throws Exception if failed to save data */ @@ -144,6 +246,7 @@ internal class B2BSessionStorage( memberSession = null member = null intermediateSessionToken = null + lastValidatedAt = Date(0L) } } } diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessions.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessions.kt index 134626bcc..a73511c04 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessions.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessions.kt @@ -4,6 +4,7 @@ import com.stytch.sdk.b2b.SessionExchangeResponse import com.stytch.sdk.b2b.SessionsAuthenticateResponse import com.stytch.sdk.b2b.network.models.B2BSessionData import com.stytch.sdk.common.BaseResponse +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.errors.StytchFailedToDecryptDataError import com.stytch.sdk.common.network.models.Locale import kotlinx.coroutines.flow.StateFlow @@ -17,13 +18,13 @@ public interface B2BSessions { /** * Exposes a flow of session data */ - public val onChange: StateFlow + public val onChange: StateFlow> /** * Assign a callback that will be called when the session data changes */ - public fun onChange(callback: (B2BSessionData?) -> Unit) + public fun onChange(callback: (StytchObjectInfo) -> Unit) /** * @throws StytchFailedToDecryptDataError if failed to decrypt data diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionsImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionsImpl.kt index 6981faa97..722254b06 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionsImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionsImpl.kt @@ -7,12 +7,17 @@ import com.stytch.sdk.b2b.network.StytchB2BApi import com.stytch.sdk.b2b.network.models.B2BSessionData import com.stytch.sdk.common.BaseResponse import com.stytch.sdk.common.StytchDispatchers +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.errors.StytchFailedToDecryptDataError import com.stytch.sdk.common.errors.StytchInternalError +import com.stytch.sdk.common.stytchObjectMapper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -24,9 +29,15 @@ internal class B2BSessionsImpl internal constructor( private val sessionStorage: B2BSessionStorage, private val api: StytchB2BApi.Sessions, ) : B2BSessions { - private val callbacks = mutableListOf<(B2BSessionData?) -> Unit>() + private val callbacks = mutableListOf<(StytchObjectInfo) -> Unit>() - override val onChange: StateFlow = sessionStorage.sessionFlow + override val onChange: StateFlow> = + combine(sessionStorage.sessionFlow, sessionStorage.lastValidatedAtFlow, ::stytchObjectMapper) + .stateIn( + externalScope, + SharingStarted.WhileSubscribed(), + stytchObjectMapper(sessionStorage.memberSession, sessionStorage.lastValidatedAt), + ) init { externalScope.launch { @@ -38,7 +49,7 @@ internal class B2BSessionsImpl internal constructor( } } - override fun onChange(callback: (B2BSessionData?) -> Unit) { + override fun onChange(callback: (StytchObjectInfo) -> Unit) { callbacks.add(callback) } diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/Constants.kt b/source/sdk/src/main/java/com/stytch/sdk/common/Constants.kt index 1076d86f7..bf4a5f067 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/common/Constants.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/common/Constants.kt @@ -30,4 +30,10 @@ internal const val PREFERENCES_NAME_SESSION_JWT = "session_jwt" internal const val PREFERENCES_NAME_SESSION_TOKEN = "session_token" internal const val PREFERENCES_NAME_IST = "intermediate_session_token" internal const val PREFERENCES_NAME_IST_EXPIRATION = "intermediate_session_token_expiration" +internal const val PREFERENCES_NAME_LAST_VALIDATED_AT = "stytch_last_validated_at" +internal const val PREFERENCES_NAME_SESSION_DATA = "stytch_session_data" +internal const val PREFERENCES_NAME_USER_DATA = "stytch_user_data" +internal const val PREFERENCES_NAME_MEMBER_SESSION_DATA = "stytch_member_session_data" +internal const val PREFERENCES_NAME_MEMBER_DATA = "stytch_member_data" +internal const val PREFERENCES_NAME_ORGANIZATION_DATA = "stytch_organization_data" internal const val IST_EXPIRATION_TIME = 10 * 60 * 1000L // 10 minutes diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/StorageHelper.kt b/source/sdk/src/main/java/com/stytch/sdk/common/StorageHelper.kt index bf5596648..7d554710a 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/common/StorageHelper.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/common/StorageHelper.kt @@ -2,6 +2,7 @@ package com.stytch.sdk.common import android.content.Context import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import java.security.KeyStore private const val KEY_ALIAS = "Stytch RSA 2048" @@ -9,7 +10,9 @@ private const val PREFERENCES_FILE_NAME = "stytch_preferences" internal object StorageHelper { internal val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore") - private lateinit var sharedPreferences: SharedPreferences + + @VisibleForTesting + internal lateinit var sharedPreferences: SharedPreferences fun initialize(context: Context) { keyStore.load(null) @@ -60,9 +63,25 @@ internal object StorageHelper { } } - internal fun getBoolean(name: String): Boolean = sharedPreferences.getBoolean(name, false) + internal fun getBoolean( + name: String, + default: Boolean = false, + ): Boolean = + try { + sharedPreferences.getBoolean(name, default) + } catch (ex: Exception) { + default + } - internal fun getLong(name: String): Long = sharedPreferences.getLong(name, 0L) + internal fun getLong( + name: String, + default: Long = 0L, + ): Long = + try { + sharedPreferences.getLong(name, default) + } catch (ex: Exception) { + default + } /** * Load and decrypt value from SharedPreferences diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/StytchObjectInfo.kt b/source/sdk/src/main/java/com/stytch/sdk/common/StytchObjectInfo.kt new file mode 100644 index 000000000..3e1b3dd64 --- /dev/null +++ b/source/sdk/src/main/java/com/stytch/sdk/common/StytchObjectInfo.kt @@ -0,0 +1,23 @@ +package com.stytch.sdk.common + +import java.util.Date + +public sealed interface StytchObjectInfo { + public data object Unavailable : StytchObjectInfo + + public data class Available( + val lastValidatedAt: Date, + val value: T, + ) : StytchObjectInfo +} + +internal inline fun stytchObjectMapper( + value: T?, + lastValidatedAt: Date, +): StytchObjectInfo = + value?.let { + StytchObjectInfo.Available( + lastValidatedAt = lastValidatedAt, + value = value, + ) + } ?: StytchObjectInfo.Unavailable diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/utils/DateHelpers.kt b/source/sdk/src/main/java/com/stytch/sdk/common/utils/DateHelpers.kt index 7ed83dd28..d1f1c7dff 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/common/utils/DateHelpers.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/common/utils/DateHelpers.kt @@ -1,7 +1,20 @@ package com.stytch.sdk.common.utils +import java.text.ParseException import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale // Format a date as ISO-8601, using the appropriate date and time patterns. See: https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html internal val ISO_DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US) + +internal val SHORT_FORM_DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + +internal fun String?.getDateOrMin(minimum: Date = Date(0L)): Date = + this?.let { date -> + try { + ISO_DATE_FORMATTER.parse(date) + } catch (e: ParseException) { + SHORT_FORM_DATE_FORMATTER.parse(date) + } + } ?: minimum diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt index 960499b19..49fb7ebf5 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt @@ -151,8 +151,12 @@ public object StytchClient { ) // if there are session identifiers on device start the auto updater to ensure it is still valid if (sessionStorage.persistedSessionIdentifiersExist) { - StytchApi.Sessions.authenticate(null).apply { - launchSessionUpdater(dispatchers, sessionStorage) + sessionStorage.session?.let { + // if we have a session, it's expiration date has already been validated, now attempt + // to validate it with the Stytch servers + StytchApi.Sessions.authenticate(null).apply { + launchSessionUpdater(dispatchers, sessionStorage) + } } } _isInitialized.value = true diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt index 92ef501fa..6cb818330 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt @@ -1,9 +1,16 @@ package com.stytch.sdk.consumer.sessions +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import com.stytch.sdk.common.PREFERENCES_NAME_LAST_VALIDATED_AT +import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_DATA import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_JWT import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_TOKEN +import com.stytch.sdk.common.PREFERENCES_NAME_USER_DATA import com.stytch.sdk.common.StorageHelper +import com.stytch.sdk.common.StytchLog import com.stytch.sdk.common.errors.StytchNoCurrentSessionError +import com.stytch.sdk.common.utils.getDateOrMin import com.stytch.sdk.consumer.extensions.keepLocalBiometricRegistrationsInSync import com.stytch.sdk.consumer.network.models.SessionData import com.stytch.sdk.consumer.network.models.UserData @@ -11,11 +18,16 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.Date internal class ConsumerSessionStorage( private val storageHelper: StorageHelper, private val externalScope: CoroutineScope, ) { + private val moshi = Moshi.Builder().build() + private val moshiSessionDataAdapter = moshi.adapter(SessionData::class.java).lenient() + private val moshiUserDataAdapter = moshi.adapter(UserData::class.java).lenient() + var sessionToken: String? private set(value) { storageHelper.saveValue(PREFERENCES_NAME_SESSION_TOKEN, value) @@ -40,27 +52,84 @@ internal class ConsumerSessionStorage( return value } - var session: SessionData? = null + var lastValidatedAt: Date + get() { + val longValue: Long? + synchronized(this) { + longValue = storageHelper.getLong(PREFERENCES_NAME_LAST_VALIDATED_AT) + } + return longValue?.let { Date(it) } ?: Date(0L) + } private set(value) { - field = value + storageHelper.saveLong(PREFERENCES_NAME_LAST_VALIDATED_AT, value.time) externalScope.launch { - _sessionFlow.emit(field) + _lastValidatedAtFlow.emit(value) } } - var user: UserData? = null - set(value) { + var session: SessionData? + get() { + val stringValue: String? synchronized(this) { - field = value + stringValue = storageHelper.loadValue(PREFERENCES_NAME_SESSION_DATA) + } + return stringValue?.let { + // convert it back to a data class, check the expiration date, expire it if expired + val sessionData = + try { + moshiSessionDataAdapter.fromJson(it) + } catch (e: JsonDataException) { + StytchLog.e(e.message ?: "Error parsing persisted SessionData") + null + } + val expirationDate = sessionData?.expiresAt.getDateOrMin() + val now = Date() + if (expirationDate.before(now)) { + revoke() + return null + } + return sessionData + } + } + private set(value) { + value?.let { + val stringValue = moshiSessionDataAdapter.toJson(it) + storageHelper.saveValue(PREFERENCES_NAME_SESSION_DATA, stringValue) + } ?: run { + storageHelper.saveValue(PREFERENCES_NAME_SESSION_DATA, null) } - value?.keepLocalBiometricRegistrationsInSync(storageHelper) + lastValidatedAt = Date() externalScope.launch { - _userFlow.emit(value) + _sessionFlow.emit(value) } } + + var user: UserData? get() { + val stringValue: String? synchronized(this) { - return field + stringValue = storageHelper.loadValue(PREFERENCES_NAME_USER_DATA) + } + return stringValue?.let { + try { + moshiUserDataAdapter.fromJson(it) + } catch (e: JsonDataException) { + StytchLog.e(e.message ?: "Error parsing persisted UserData") + null + } + } + } + internal set(value) { + value?.let { + it.keepLocalBiometricRegistrationsInSync(storageHelper) + val stringValue = moshiUserDataAdapter.toJson(it) + storageHelper.saveValue(PREFERENCES_NAME_USER_DATA, stringValue) + } ?: run { + storageHelper.saveValue(PREFERENCES_NAME_USER_DATA, null) + } + lastValidatedAt = Date() + externalScope.launch { + _userFlow.emit(value) } } @@ -70,6 +139,9 @@ internal class ConsumerSessionStorage( private val _userFlow = MutableStateFlow(user) val userFlow = _userFlow.asStateFlow() + private val _lastValidatedAtFlow = MutableStateFlow(lastValidatedAt) + val lastValidatedAtFlow = _lastValidatedAtFlow.asStateFlow() + val persistedSessionIdentifiersExist: Boolean get() = sessionToken != null || sessionJwt != null @@ -99,6 +171,7 @@ internal class ConsumerSessionStorage( sessionJwt = null session = null user = null + lastValidatedAt = Date(0L) } } diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/Sessions.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/Sessions.kt index e374b30b0..9231f4329 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/Sessions.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/Sessions.kt @@ -1,6 +1,7 @@ package com.stytch.sdk.consumer.sessions import com.stytch.sdk.common.BaseResponse +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.errors.StytchFailedToDecryptDataError import com.stytch.sdk.consumer.AuthResponse import com.stytch.sdk.consumer.network.models.SessionData @@ -15,13 +16,13 @@ public interface Sessions { /** * Exposes a flow of session data */ - public val onChange: StateFlow + public val onChange: StateFlow> /** * Assign a callback that will be called when the session data changes */ - public fun onChange(callback: (SessionData?) -> Unit) + public fun onChange(callback: (StytchObjectInfo) -> Unit) /** * @throws StytchFailedToDecryptDataError if failed to decrypt data diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/SessionsImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/SessionsImpl.kt index 8825fad0e..3b1a7fdac 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/SessionsImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/SessionsImpl.kt @@ -2,16 +2,21 @@ package com.stytch.sdk.consumer.sessions import com.stytch.sdk.common.BaseResponse import com.stytch.sdk.common.StytchDispatchers +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.errors.StytchFailedToDecryptDataError import com.stytch.sdk.common.errors.StytchInternalError +import com.stytch.sdk.common.stytchObjectMapper import com.stytch.sdk.consumer.AuthResponse import com.stytch.sdk.consumer.extensions.launchSessionUpdater import com.stytch.sdk.consumer.network.StytchApi import com.stytch.sdk.consumer.network.models.SessionData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,9 +28,15 @@ internal class SessionsImpl internal constructor( private val sessionStorage: ConsumerSessionStorage, private val api: StytchApi.Sessions, ) : Sessions { - private val callbacks = mutableListOf<(SessionData?) -> Unit>() + private val callbacks = mutableListOf<(StytchObjectInfo) -> Unit>() - override val onChange: StateFlow = sessionStorage.sessionFlow + override val onChange: StateFlow> = + combine(sessionStorage.sessionFlow, sessionStorage.lastValidatedAtFlow, ::stytchObjectMapper) + .stateIn( + externalScope, + SharingStarted.WhileSubscribed(), + stytchObjectMapper(sessionStorage.session, sessionStorage.lastValidatedAt), + ) init { externalScope.launch { @@ -37,7 +48,7 @@ internal class SessionsImpl internal constructor( } } - override fun onChange(callback: (SessionData?) -> Unit) { + override fun onChange(callback: (StytchObjectInfo) -> Unit) { callbacks.add(callback) } diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagement.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagement.kt index bbb0086e0..dbdb20a3d 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagement.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagement.kt @@ -1,5 +1,6 @@ package com.stytch.sdk.consumer.userManagement +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.network.models.NameData import com.stytch.sdk.consumer.DeleteFactorResponse import com.stytch.sdk.consumer.SearchUserResponse @@ -17,13 +18,13 @@ public interface UserManagement { /** * Exposes a flow of user data */ - public val onChange: StateFlow + public val onChange: StateFlow> /** * Assign a callback that will be called when the user data changes */ - public fun onChange(callback: (UserData?) -> Unit) + public fun onChange(callback: (StytchObjectInfo) -> Unit) /** * Fetches a user from the current session diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagementImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagementImpl.kt index 43f605909..96646b0c9 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagementImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagementImpl.kt @@ -1,7 +1,9 @@ package com.stytch.sdk.consumer.userManagement import com.stytch.sdk.common.StytchDispatchers +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.StytchResult +import com.stytch.sdk.common.stytchObjectMapper import com.stytch.sdk.consumer.DeleteFactorResponse import com.stytch.sdk.consumer.SearchUserResponse import com.stytch.sdk.consumer.UpdateUserResponse @@ -11,7 +13,10 @@ import com.stytch.sdk.consumer.network.models.UserData import com.stytch.sdk.consumer.sessions.ConsumerSessionStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,9 +28,15 @@ internal class UserManagementImpl( private val sessionStorage: ConsumerSessionStorage, private val api: StytchApi.UserManagement, ) : UserManagement { - private val callbacks = mutableListOf<(UserData?) -> Unit>() - - override val onChange: StateFlow = sessionStorage.userFlow + private val callbacks = mutableListOf<(StytchObjectInfo) -> Unit>() + + override val onChange: StateFlow> = + combine(sessionStorage.userFlow, sessionStorage.lastValidatedAtFlow, ::stytchObjectMapper) + .stateIn( + externalScope, + SharingStarted.WhileSubscribed(), + stytchObjectMapper(sessionStorage.user, sessionStorage.lastValidatedAt), + ) init { externalScope.launch { @@ -37,7 +48,7 @@ internal class UserManagementImpl( } } - override fun onChange(callback: (UserData?) -> Unit) { + override fun onChange(callback: (StytchObjectInfo) -> Unit) { callbacks.add(callback) } diff --git a/source/sdk/src/main/java/com/stytch/sdk/ui/AuthenticationActivity.kt b/source/sdk/src/main/java/com/stytch/sdk/ui/AuthenticationActivity.kt index 93f279e08..ef2162280 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/ui/AuthenticationActivity.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/ui/AuthenticationActivity.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import cafe.adriel.voyager.androidx.AndroidScreen +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.errors.StytchUIInvalidConfiguration import com.stytch.sdk.consumer.StytchClient @@ -41,8 +42,8 @@ internal class AuthenticationActivity : ComponentActivity() { details = mapOf("options" to uiConfig.productConfig), ) StytchClient.user.onChange { - it?.let { - returnAuthenticationResult(StytchResult.Success(it)) + if (it is StytchObjectInfo.Available) { + returnAuthenticationResult(StytchResult.Success(it.value)) } } } diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt index cd3a0f358..79fbb8eae 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt @@ -4,11 +4,11 @@ import android.app.Application import android.content.Context import android.net.Uri import com.google.android.recaptcha.Recaptcha +import com.squareup.moshi.Moshi import com.stytch.sdk.b2b.extensions.launchSessionUpdater -import com.stytch.sdk.b2b.magicLinks.B2BMagicLinks import com.stytch.sdk.b2b.network.StytchB2BApi +import com.stytch.sdk.b2b.network.models.B2BSessionData import com.stytch.sdk.b2b.network.models.SessionsAuthenticateResponseData -import com.stytch.sdk.b2b.oauth.OAuth import com.stytch.sdk.common.DeeplinkHandledStatus import com.stytch.sdk.common.DeviceInfo import com.stytch.sdk.common.EncryptionManager @@ -23,6 +23,7 @@ import com.stytch.sdk.common.errors.StytchInternalError import com.stytch.sdk.common.errors.StytchSDKNotConfiguredError import com.stytch.sdk.common.extensions.getDeviceInfo import com.stytch.sdk.common.pkcePairManager.PKCEPairManager +import com.stytch.sdk.common.utils.SHORT_FORM_DATE_FORMATTER import io.mockk.MockKAnnotations import io.mockk.clearAllMocks import io.mockk.coEvery @@ -44,23 +45,17 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date internal class StytchB2BClientTest { private var mContextMock = mockk(relaxed = true) private val dispatcher = Dispatchers.Unconfined - @MockK - private lateinit var mockMagicLinks: B2BMagicLinks - - @MockK - private lateinit var mockOAuth: OAuth - @MockK private lateinit var mockPKCEPairManager: PKCEPairManager @@ -95,7 +90,10 @@ internal class StytchB2BClientTest { MockKAnnotations.init(this, true, true) coEvery { Recaptcha.getClient(any(), any()) } returns Result.success(mockk(relaxed = true)) every { StorageHelper.initialize(any()) } just runs - every { StorageHelper.loadValue(any()) } returns "" + every { StorageHelper.loadValue(any()) } returns "{}" + every { StorageHelper.saveValue(any(), any()) } just runs + every { StorageHelper.saveLong(any(), any()) } just runs + every { StorageHelper.getLong(any()) } returns 0 every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns PKCECodePair("", "") every { mockPKCEPairManager.getPKCECodePair() } returns mockk() coEvery { StytchB2BApi.getBootstrapData() } returns StytchResult.Error(mockk()) @@ -176,8 +174,35 @@ internal class StytchB2BClientTest { StytchB2BClient.configure(mContextMock, "") coVerify(exactly = 0) { StytchB2BApi.Sessions.authenticate(any()) } verify(exactly = 0) { mockResponse.launchSessionUpdater(any(), any()) } - // yes session data == yes authentication/updater - every { StorageHelper.loadValue(any()) } returns "some-session-data" + // yes session data, but expired, no authentication/updater + val mockExpiredSession = + mockk(relaxed = true) { + every { expiresAt } returns SHORT_FORM_DATE_FORMATTER.format(Date(0L)) + } + val mockExpiredSessionJSON = + Moshi + .Builder() + .build() + .adapter(B2BSessionData::class.java) + .lenient() + .toJson(mockExpiredSession) + every { StorageHelper.loadValue(any()) } returns mockExpiredSessionJSON + StytchB2BClient.configure(mContextMock, "") + coVerify(exactly = 0) { StytchB2BApi.Sessions.authenticate() } + verify(exactly = 0) { mockResponse.launchSessionUpdater(any(), any()) } + // yes session data, and valid, yes authentication/updater + val mockValidSession = + mockk(relaxed = true) { + every { expiresAt } returns SHORT_FORM_DATE_FORMATTER.format(Date(Date().time + 1000)) + } + val mockValidSessionJSON = + Moshi + .Builder() + .build() + .adapter(B2BSessionData::class.java) + .lenient() + .toJson(mockValidSession) + every { StorageHelper.loadValue(any()) } returns mockValidSessionJSON StytchB2BClient.configure(mContextMock, "") coVerify(exactly = 1) { StytchB2BApi.Sessions.authenticate() } verify(exactly = 1) { mockResponse.launchSessionUpdater(any(), any()) } @@ -186,7 +211,7 @@ internal class StytchB2BClientTest { @Test fun `should report the initialization state after configuration and initialization is complete`() { - runTest { + runBlocking { val mockResponse: StytchResult = mockk { every { launchSessionUpdater(any(), any()) } just runs diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/discovery/DiscoveryImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/discovery/DiscoveryImplTest.kt index c1e75e4f8..49a812880 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/discovery/DiscoveryImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/discovery/DiscoveryImplTest.kt @@ -18,8 +18,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -55,7 +55,7 @@ internal class DiscoveryImplTest { @Test fun `DiscoveryImpl organizations delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.discoverOrganizations(any()) } returns StytchResult.Success(mockk(relaxed = true)) val response = impl.listOrganizations() assert(response is StytchResult.Success) @@ -72,7 +72,7 @@ internal class DiscoveryImplTest { @Test fun `DiscoveryImpl exchangeSession delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.exchangeSession(any(), any(), any()) } returns StytchResult.Success(mockk(relaxed = true)) val response = impl.exchangeIntermediateSession(mockk(relaxed = true)) assert(response is StytchResult.Success) @@ -89,7 +89,7 @@ internal class DiscoveryImplTest { @Test fun `DiscoveryImpl create delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.createOrganization(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns StytchResult.Success(mockk(relaxed = true)) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/magicLinks/B2BMagicLinksImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/magicLinks/B2BMagicLinksImplTest.kt index 4f5a2d2e9..9555fdb86 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/magicLinks/B2BMagicLinksImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/magicLinks/B2BMagicLinksImplTest.kt @@ -29,8 +29,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -89,7 +89,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl authenticate delegates to api when code verifier is found`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns mockk(relaxed = true) coEvery { mockEmailApi.authenticate(any(), any(), any(), any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) @@ -101,7 +101,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl authenticate delegates to api when code verifier is not found`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null coEvery { mockEmailApi.authenticate(any(), any(), any(), any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) @@ -122,7 +122,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl email loginOrCreate returns error if generateCodeChallenge fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } throws RuntimeException("Test") val response = impl.email.loginOrSignup(emailMagicLinkParameters) assert(response is StytchResult.Error) @@ -130,7 +130,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl email loginOrCreate delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns PKCECodePair("", "") coEvery { mockEmailApi.loginOrSignupByEmail(any(), any(), any(), any(), any(), any(), any(), any()) @@ -148,7 +148,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl email invite delegates to api`() = - runTest { + runBlocking { coEvery { mockEmailApi.invite(any(), any(), any(), any(), any(), any(), any()) } returns mockMemberResponse @@ -168,7 +168,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl email sendDiscovery returns error if generateCodeChallenge fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } throws RuntimeException("Test") val response = impl.email.discoverySend(mockk(relaxed = true)) assert(response is StytchResult.Error) @@ -176,7 +176,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl discovery send delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns PKCECodePair("", "") coEvery { mockDiscoveryApi.send(any(), any(), any(), any(), any()) } returns mockBaseResponse impl.email.discoverySend(mockk(relaxed = true)) @@ -192,7 +192,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl discovery authenticate returns error if retrieveCodeVerifier fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val response = impl.discoveryAuthenticate(mockk(relaxed = true)) assert(response is StytchResult.Error) @@ -200,7 +200,7 @@ internal class B2BMagicLinksImplTest { @Test fun `MagicLinksImpl discovery authenticate delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns mockk(relaxed = true) coEvery { mockDiscoveryApi.authenticate(any(), any()) } returns mockk(relaxed = true) impl.discoveryAuthenticate(mockk(relaxed = true)) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/member/MemberImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/member/MemberImplTest.kt index d2e438def..378c73580 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/member/MemberImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/member/MemberImplTest.kt @@ -1,5 +1,7 @@ package com.stytch.sdk.b2b.member +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor import com.stytch.sdk.b2b.DeleteMemberAuthenticationFactorResponse import com.stytch.sdk.b2b.MemberResponse import com.stytch.sdk.b2b.UpdateMemberResponse @@ -7,6 +9,9 @@ import com.stytch.sdk.b2b.network.StytchB2BApi import com.stytch.sdk.b2b.network.models.MemberData import com.stytch.sdk.b2b.network.models.MemberResponseData import com.stytch.sdk.b2b.sessions.B2BSessionStorage +import com.stytch.sdk.common.EncryptionManager +import com.stytch.sdk.common.PREFERENCES_NAME_LAST_VALIDATED_AT +import com.stytch.sdk.common.PREFERENCES_NAME_MEMBER_DATA import com.stytch.sdk.common.StorageHelper import com.stytch.sdk.common.StytchDispatchers import com.stytch.sdk.common.StytchResult @@ -16,14 +21,17 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -33,6 +41,12 @@ internal class MemberImplTest { @MockK private lateinit var mockApi: StytchB2BApi.Member + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockSharedPreferencesEditor: Editor + private lateinit var spiedSessionStorage: B2BSessionStorage private lateinit var impl: MemberImpl @@ -41,9 +55,18 @@ internal class MemberImplTest { @Before fun before() { - MockKAnnotations.init(this, true, true) mockkStatic(KeyStore::class) + mockkObject(EncryptionManager) + every { EncryptionManager.createNewKeys(any(), any()) } returns Unit + every { EncryptionManager.encryptString(any()) } returns "" every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) + MockKAnnotations.init(this, true, true) + every { mockSharedPreferences.edit() } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.apply() } just runs + every { mockSharedPreferences.getLong(any(), any()) } returns 0 + StorageHelper.sharedPreferences = mockSharedPreferences spiedSessionStorage = spyk(B2BSessionStorage(StorageHelper, TestScope()), recordPrivateCalls = true) impl = MemberImpl( @@ -62,12 +85,15 @@ internal class MemberImplTest { @Test fun `Member get delegates to api and caches the result`() = - runTest { + runBlocking { coEvery { mockApi.getMember() } returns successfulMemberResponse + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor val response = impl.get() assert(response is StytchResult.Success) coVerify { mockApi.getMember() } - assert(spiedSessionStorage.member == successfulMemberResponse.value.member) + verify { mockSharedPreferencesEditor.putString(PREFERENCES_NAME_MEMBER_DATA, any()) } + verify { mockSharedPreferencesEditor.putLong(PREFERENCES_NAME_LAST_VALIDATED_AT, any()) } } @Test @@ -89,7 +115,7 @@ internal class MemberImplTest { @Test fun `Member update delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.updateMember(any(), any(), any(), any(), any()) } returns mockk(relaxed = true) impl.update(mockk(relaxed = true)) coVerify { mockApi.updateMember(any(), any(), any(), any(), any()) } @@ -105,7 +131,7 @@ internal class MemberImplTest { @Test fun `Member deleteFactor delegates to api for all supported factors`() = - runTest { + runBlocking { coEvery { mockApi.deleteMFAPhoneNumber() } returns StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.deleteMFATOTP() } returns StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.deletePassword(any()) } returns StytchResult.Success(mockk(relaxed = true)) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/network/StytchB2BApiTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/network/StytchB2BApiTest.kt index fc7f149bc..d57924ba2 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/network/StytchB2BApiTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/network/StytchB2BApiTest.kt @@ -29,7 +29,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test @@ -95,7 +95,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi MagicLinks Email loginOrCreate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.loginOrSignupByEmail(any()) } returns mockk(relaxed = true) StytchB2BApi.MagicLinks.Email.loginOrSignupByEmail("", "", "", "", "", "", "", null) @@ -104,7 +104,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi MagicLinks Email authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.authenticate(any()) } returns mockk(relaxed = true) StytchB2BApi.MagicLinks.Email.authenticate("", 30, "") @@ -113,7 +113,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi MagicLinks Email invite calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.sendInviteMagicLink(any()) } returns mockk(relaxed = true) StytchB2BApi.MagicLinks.Email.invite("email@address.com") @@ -122,7 +122,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi MagicLinks Discovery send calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.sendDiscoveryMagicLink(any()) } returns mockk(relaxed = true) StytchB2BApi.MagicLinks.Discovery.send("", "", "", "", Locale.EN) @@ -131,7 +131,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi MagicLinks Discovery authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.authenticateDiscoveryMagicLink(any()) } returns mockk(relaxed = true) StytchB2BApi.MagicLinks.Discovery.authenticate("", "") @@ -140,7 +140,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Sessions authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.authenticateSessions(any()) } returns mockk(relaxed = true) StytchB2BApi.Sessions.authenticate(30) @@ -149,7 +149,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Sessions revoke calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.revokeSessions() } returns mockk(relaxed = true) StytchB2BApi.Sessions.revoke() @@ -158,7 +158,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Sessions exchange calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.exchangeSession(any()) } returns mockk(relaxed = true) StytchB2BApi.Sessions.exchange(organizationId = "test-123", sessionDurationMinutes = 30) @@ -167,7 +167,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organizations getOrganization calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.getOrganization() } returns mockk(relaxed = true) StytchB2BApi.Organization.getOrganization() @@ -176,7 +176,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organizations update calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.updateOrganization(any()) } returns mockk(relaxed = true) StytchB2BApi.Organization.updateOrganization() @@ -185,7 +185,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organizations delete calls appropriate apiService method()`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteOrganization() } returns mockk(relaxed = true) StytchB2BApi.Organization.deleteOrganization() @@ -194,7 +194,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organizations member delete calls appropriate apiService method()`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteOrganizationMember(any()) } returns mockk(relaxed = true) StytchB2BApi.Organization.deleteOrganizationMember("my-member-id") @@ -203,7 +203,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organizations member reactivate calls appropriate apiService method()`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.reactivateOrganizationMember(any()) } returns mockk(relaxed = true) StytchB2BApi.Organization.reactivateOrganizationMember("my-member-id") @@ -212,7 +212,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organization deleteOrganizationMemberMFAPhoneNumber calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteOrganizationMemberMFAPhoneNumber( @@ -225,7 +225,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organization deleteOrganizationMemberMFATOTP calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteOrganizationMemberMFATOTP(any()) } returns mockk(relaxed = true) StytchB2BApi.Organization.deleteOrganizationMemberMFATOTP("my-member-id") @@ -234,7 +234,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organization deleteOrganizationMemberPassword calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteOrganizationMemberPassword( @@ -247,7 +247,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organization createOrganizationMember calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.createMember(any()) } returns mockk(relaxed = true) StytchB2BApi.Organization.createOrganizationMember( @@ -265,7 +265,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organization updateOrganizationMember calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.updateOrganizationMember( @@ -290,7 +290,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Organization searchMembers calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.searchMembers(any()) @@ -301,7 +301,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Member get calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.getMember() } returns mockk(relaxed = true) StytchB2BApi.Member.getMember() @@ -310,7 +310,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Member update calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.updateMember(any()) } returns mockk(relaxed = true) StytchB2BApi.Member.updateMember("", emptyMap(), false, "", MfaMethod.SMS) @@ -319,7 +319,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Member deleteMFAPhoneNumber calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteMFAPhoneNumber() } returns mockk(relaxed = true) StytchB2BApi.Member.deleteMFAPhoneNumber() @@ -328,7 +328,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Member deleteMFATOTP calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deleteMFATOTP() } returns mockk(relaxed = true) StytchB2BApi.Member.deleteMFATOTP() @@ -337,7 +337,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Member deletePassword calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.deletePassword("passwordId") } returns mockk(relaxed = true) StytchB2BApi.Member.deletePassword("passwordId") @@ -346,7 +346,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Passwords authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.authenticatePassword(any()) } returns mockk(relaxed = true) StytchB2BApi.Passwords.authenticate("", "", "") @@ -355,7 +355,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Passwords resetByEmailStart calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.resetPasswordByEmailStart(any()) } returns mockk(relaxed = true) StytchB2BApi.Passwords.resetByEmailStart( @@ -372,7 +372,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Passwords resetByEmail calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.resetPasswordByEmail(any()) } returns mockk(relaxed = true) StytchB2BApi.Passwords.resetByEmail(passwordResetToken = "", password = "", codeVerifier = "") @@ -381,7 +381,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Passwords resetByExisting calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.resetPasswordByExisting(any()) } returns mockk(relaxed = true) StytchB2BApi.Passwords.resetByExisting( @@ -395,7 +395,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Passwords resetBySession calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.resetPasswordBySession(any()) } returns mockk(relaxed = true) StytchB2BApi.Passwords.resetBySession(organizationId = "", password = "") @@ -404,7 +404,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Passwords strengthCheck calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.passwordStrengthCheck(any()) } returns mockk(relaxed = true) StytchB2BApi.Passwords.strengthCheck(email = "", password = "") @@ -413,7 +413,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Discovery discoverOrganizations calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.discoverOrganizations(any()) } returns mockk(relaxed = true) StytchB2BApi.Discovery.discoverOrganizations(null) @@ -422,7 +422,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Discovery exchangeSession calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.intermediateSessionExchange(any()) } returns mockk(relaxed = true) StytchB2BApi.Discovery.exchangeSession( @@ -435,7 +435,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Discovery createOrganization calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.createOrganization(any()) } returns mockk(relaxed = true) StytchB2BApi.Discovery.createOrganization( @@ -470,7 +470,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoAuthenticate(any()) } returns mockk(relaxed = true) StytchB2BApi.SSO.authenticate( @@ -483,7 +483,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO getConnections calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoGetConnections() } returns mockk(relaxed = true) StytchB2BApi.SSO.getConnections() @@ -492,7 +492,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO deleteConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoDeleteConnection(any()) } returns mockk(relaxed = true) val connectionId = "my-connection-id" @@ -502,7 +502,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO SAML createConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoSamlCreate(any()) } returns mockk(relaxed = true) val displayName = "my cool saml connection" @@ -516,7 +516,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO SAML updateConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoSamlUpdate(any(), any()) } returns mockk(relaxed = true) val connectionId = "my-connection-id" @@ -528,7 +528,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO SAML updateConnectionByUrl calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoSamlUpdateByUrl(any(), any()) } returns mockk(relaxed = true) val connectionId = "my-connection-id" @@ -541,7 +541,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO SAML samlDeleteVerificationCertificate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoSamlDeleteVerificationCertificate( @@ -565,7 +565,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO OIDC createConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoOidcCreate(any()) } returns mockk(relaxed = true) val displayName = "my cool oidc connection" @@ -579,7 +579,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SSO OIDC updateConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.ssoOidcUpdate(any(), any()) } returns mockk(relaxed = true) val connectionId = "my-cool-oidc-connection" @@ -594,7 +594,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Bootstrap getBootstrapData calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true every { StytchB2BApi.publicToken } returns "mock-public-token" coEvery { StytchB2BApi.apiService.getBootstrapData("mock-public-token") } returns mockk(relaxed = true) @@ -604,7 +604,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi Events logEvent calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true every { StytchB2BApi.publicToken } returns "mock-public-token" coEvery { StytchB2BApi.apiService.logEvent(any()) } returns mockk(relaxed = true) @@ -666,7 +666,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi OTP sendSMSOTP calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.sendSMSOTP(any()) } returns mockk(relaxed = true) StytchB2BApi.OTP.sendSMSOTP("", "") @@ -675,7 +675,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi OTP authenticateSMSOTP calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.authenticateSMSOTP(any()) } returns mockk(relaxed = true) StytchB2BApi.OTP.authenticateSMSOTP("", "", "", null, 30) @@ -684,7 +684,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi TOTP create calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.createTOTP(any()) } returns mockk(relaxed = true) StytchB2BApi.TOTP.create("", "") @@ -693,7 +693,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi TOTP authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.authenticateTOTP(any()) } returns mockk(relaxed = true) StytchB2BApi.TOTP.authenticate("", "", "", null, null, 30) @@ -702,7 +702,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi RecoveryCodes get calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.getRecoveryCodes() } returns mockk(relaxed = true) StytchB2BApi.RecoveryCodes.get() @@ -711,7 +711,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi RecoveryCodes rotate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.rotateRecoveryCodes() } returns mockk(relaxed = true) StytchB2BApi.RecoveryCodes.rotate() @@ -720,7 +720,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi RecoveryCodes recover calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.recoverRecoveryCodes(any()) } returns mockk(relaxed = true) StytchB2BApi.RecoveryCodes.recover("", "", 30, "") @@ -729,7 +729,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi OAuth authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.oauthAuthenticate(any()) } returns mockk(relaxed = true) StytchB2BApi.OAuth.authenticate("", Locale.EN, 30, "", "") @@ -738,7 +738,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi OAuth discoveryAuthenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.oauthDiscoveryAuthenticate(any()) } returns mockk(relaxed = true) StytchB2BApi.OAuth.discoveryAuthenticate("", "") @@ -747,7 +747,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SearchManager searchOrganizations calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.searchOrganizations(any()) } returns mockk(relaxed = true) StytchB2BApi.SearchManager.searchOrganizations("organization-slug") @@ -756,7 +756,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SearchManager searchOrganizationMembers calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.searchOrganizationMembers(any()) } returns mockk(relaxed = true) StytchB2BApi.SearchManager.searchMembers("email@example.com", "organization-id") @@ -765,7 +765,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM createConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimCreateConnection(any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.createConnection("", "") @@ -774,7 +774,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM updateConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimUpdateConnection(any(), any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.updateConnection("connection-id", "", "", emptyList()) @@ -783,7 +783,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM deleteConection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimDeleteConnection(any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.deleteConection("connection-id") @@ -792,7 +792,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM getConnection calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimGetConnection() } returns mockk(relaxed = true) StytchB2BApi.SCIM.getConnection() @@ -801,7 +801,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM getConnectionGroups calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimGetConnectionGroups(any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.getConnectionGroups("", 1000) @@ -810,7 +810,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM rotateStart calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimRotateStart(any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.rotateStart("connection-id") @@ -819,7 +819,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM rotateCancel calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimRotateCancel(any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.rotateCancel("connection-id") @@ -828,7 +828,7 @@ internal class StytchB2BApiTest { @Test fun `StytchB2BApi SCIM rotateComplete calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true coEvery { StytchB2BApi.apiService.scimRotateComplete(any()) } returns mockk(relaxed = true) StytchB2BApi.SCIM.rotateComplete("connection-id") @@ -836,8 +836,8 @@ internal class StytchB2BApiTest { } @Test(expected = StytchSDKNotConfiguredError::class) - fun `safeApiCall throws exception when StytchB2BClient is not initialized`() = - runTest { + fun `safeApiCall throws exception when StytchB2BClient is not initialized`(): Unit = + runBlocking { every { StytchB2BApi.isInitialized } returns false val mockApiCall: suspend () -> StytchDataResponse = mockk() StytchB2BApi.safeB2BApiCall { mockApiCall() } @@ -845,7 +845,7 @@ internal class StytchB2BApiTest { @Test fun `safeApiCall returns success when call succeeds`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse = StytchDataResponse(true) @@ -855,7 +855,7 @@ internal class StytchB2BApiTest { @Test fun `safeApiCall returns correct error for HttpException`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse = @@ -870,7 +870,7 @@ internal class StytchB2BApiTest { @Test fun `safeApiCall returns correct error for StytchErrors`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse = throw StytchAPIError(errorType = "", message = "") @@ -880,7 +880,7 @@ internal class StytchB2BApiTest { @Test fun `safeApiCall returns correct error for other exceptions`() = - runTest { + runBlocking { every { StytchB2BApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse { diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/oauth/OAuthImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/oauth/OAuthImplTest.kt index 8eeae2d36..9f65b2662 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/oauth/OAuthImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/oauth/OAuthImplTest.kt @@ -27,12 +27,13 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date internal class OAuthImplTest { @MockK @@ -61,6 +62,7 @@ internal class OAuthImplTest { mockkObject(StytchB2BApi) every { StytchB2BApi.isInitialized } returns true every { mockSessionStorage.intermediateSessionToken } returns null + every { mockSessionStorage.lastValidatedAt } returns Date(0) StytchB2BClient.deviceInfo = mockk(relaxed = true) StytchB2BClient.appSessionId = "app-session-id" impl = @@ -81,7 +83,7 @@ internal class OAuthImplTest { @Test fun `authenticate returns correct error if PKCE is missing`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val result = impl.authenticate(mockk(relaxed = true)) require(result is StytchResult.Error) @@ -90,7 +92,7 @@ internal class OAuthImplTest { @Test fun `authenticate returns correct error if api call fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("code-challenge", "code-verifier") coEvery { mockApi.authenticate(any(), any(), any(), any(), any()) } returns StytchResult.Error( @@ -105,7 +107,7 @@ internal class OAuthImplTest { @Test fun `authenticate returns success if api call succeeds`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("code-challenge", "code-verifier") coEvery { mockApi.authenticate(any(), any(), any(), any(), any()) } returns StytchResult.Success( diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/organization/OrganizationImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/organization/OrganizationImplTest.kt index e769d7d36..8c33ad48f 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/organization/OrganizationImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/organization/OrganizationImplTest.kt @@ -1,5 +1,7 @@ package com.stytch.sdk.b2b.organization +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor import com.stytch.sdk.b2b.CreateMemberResponse import com.stytch.sdk.b2b.DeleteMemberResponse import com.stytch.sdk.b2b.DeleteOrganizationMemberAuthenticationFactorResponse @@ -18,6 +20,9 @@ import com.stytch.sdk.b2b.network.models.OrganizationResponseData import com.stytch.sdk.b2b.network.models.OrganizationUpdateResponseData import com.stytch.sdk.b2b.network.models.SearchOperator import com.stytch.sdk.b2b.sessions.B2BSessionStorage +import com.stytch.sdk.common.EncryptionManager +import com.stytch.sdk.common.PREFERENCES_NAME_LAST_VALIDATED_AT +import com.stytch.sdk.common.PREFERENCES_NAME_ORGANIZATION_DATA import com.stytch.sdk.common.StorageHelper import com.stytch.sdk.common.StytchDispatchers import com.stytch.sdk.common.StytchResult @@ -36,8 +41,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -47,6 +52,12 @@ internal class OrganizationImplTest { @MockK private lateinit var mockApi: StytchB2BApi.Organization + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockSharedPreferencesEditor: Editor + private lateinit var spiedSessionStorage: B2BSessionStorage private lateinit var impl: OrganizationImpl @@ -56,11 +67,18 @@ internal class OrganizationImplTest { @Before fun before() { - MockKAnnotations.init(this, true, true) mockkStatic(KeyStore::class) + mockkObject(EncryptionManager) + every { EncryptionManager.createNewKeys(any(), any()) } returns Unit + every { EncryptionManager.encryptString(any()) } returns "" every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) - mockkObject(StorageHelper) - every { StorageHelper.saveValue(any(), any()) } just runs + MockKAnnotations.init(this, true, true) + every { mockSharedPreferences.edit() } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferences.getLong(any(), any()) } returns 0 + every { mockSharedPreferencesEditor.apply() } just runs + StorageHelper.sharedPreferences = mockSharedPreferences spiedSessionStorage = spyk(B2BSessionStorage(StorageHelper, TestScope()), recordPrivateCalls = true) impl = OrganizationImpl( @@ -79,12 +97,15 @@ internal class OrganizationImplTest { @Test fun `Organizations getOrganization delegates to api and caches the organization`() = - runTest { + runBlocking { coEvery { mockApi.getOrganization() } returns successfulOrgResponse + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor val response = impl.get() assert(response is StytchResult.Success) coVerify { mockApi.getOrganization() } - assert(spiedSessionStorage.organization == successfulOrgResponse.value.organization) + verify { mockSharedPreferencesEditor.putString(PREFERENCES_NAME_ORGANIZATION_DATA, any()) } + verify { mockSharedPreferencesEditor.putLong(PREFERENCES_NAME_LAST_VALIDATED_AT, any()) } } @Test @@ -106,7 +127,7 @@ internal class OrganizationImplTest { @Test fun `Organizations update delegates to api and caches the updated organization`() = - runTest { + runBlocking { val mockResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.updateOrganization( @@ -127,10 +148,13 @@ internal class OrganizationImplTest { any(), ) } returns mockResponse + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor val response = impl.update(Organization.UpdateOrganizationParameters()) assert(response is StytchResult.Success) coVerify { mockApi.updateOrganization() } - assert(spiedSessionStorage.organization == mockResponse.value.organization) + verify { mockSharedPreferencesEditor.putString(PREFERENCES_NAME_ORGANIZATION_DATA, any()) } + verify { mockSharedPreferencesEditor.putLong(PREFERENCES_NAME_LAST_VALIDATED_AT, any()) } } @Test @@ -161,7 +185,7 @@ internal class OrganizationImplTest { @Test fun `Organizations delete delegates to api and clears all cached data`() = - runTest { + runBlocking { coEvery { mockApi.deleteOrganization() } returns successfulDeleteResponse val response = impl.delete() assert(response is StytchResult.Success) @@ -183,7 +207,7 @@ internal class OrganizationImplTest { @Test fun `Organization member delete delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.deleteOrganizationMember(any()) } returns mockk(relaxed = true) impl.members.delete("my-member-id") coVerify { mockApi.deleteOrganizationMember("my-member-id") } @@ -199,7 +223,7 @@ internal class OrganizationImplTest { @Test fun `Organization member reactivate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.reactivateOrganizationMember(any()) } returns mockk(relaxed = true) impl.members.reactivate("my-member-id") coVerify { mockApi.reactivateOrganizationMember("my-member-id") } @@ -227,7 +251,7 @@ internal class OrganizationImplTest { @Test fun `Organization Member deleteFactor delegates to api for all supported factors`() = - runTest { + runBlocking { coEvery { mockApi.deleteOrganizationMemberMFAPhoneNumber( any(), @@ -255,7 +279,7 @@ internal class OrganizationImplTest { @Test fun `Organization member create delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.createOrganizationMember(any(), any(), any(), any(), any(), any(), any(), any()) } returns mockk(relaxed = true) @@ -295,7 +319,7 @@ internal class OrganizationImplTest { @Test fun `Organization member update delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.updateOrganizationMember(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns mockk(relaxed = true) @@ -339,7 +363,7 @@ internal class OrganizationImplTest { @Test fun `Organization member search creates the expected operand values`() = - runTest { + runBlocking { coEvery { mockApi.search(any(), any(), any()) } returns mockk(relaxed = true) val memberIdsOperand = Organization.OrganizationMembers.SearchQueryOperand.MemberIds( diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/otp/OTPImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/otp/OTPImplTest.kt index 8f94ba61f..7f11805b6 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/otp/OTPImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/otp/OTPImplTest.kt @@ -1,5 +1,7 @@ package com.stytch.sdk.b2b.otp +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor import com.stytch.sdk.b2b.BasicResponse import com.stytch.sdk.b2b.SMSAuthenticateResponse import com.stytch.sdk.b2b.extensions.launchSessionUpdater @@ -26,8 +28,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -37,6 +39,12 @@ internal class OTPImplTest { @MockK private lateinit var mockApi: StytchB2BApi.OTP + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockSharedPreferencesEditor: Editor + private lateinit var spiedSessionStorage: B2BSessionStorage private lateinit var impl: OTPImpl @@ -50,7 +58,12 @@ internal class OTPImplTest { mockkStatic(KeyStore::class) every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) mockkObject(StorageHelper) - every { StorageHelper.saveValue(any(), any()) } just runs + every { mockSharedPreferences.edit() } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferences.getLong(any(), any()) } returns 0L + every { mockSharedPreferencesEditor.apply() } just runs + StorageHelper.sharedPreferences = mockSharedPreferences mockkObject(SessionAutoUpdater) mockkStatic("com.stytch.sdk.b2b.extensions.StytchResultExtKt") every { SessionAutoUpdater.startSessionUpdateJob(any(), any(), any()) } just runs @@ -72,7 +85,7 @@ internal class OTPImplTest { @Test fun `OTP SMS Send delegates to the API`() = - runTest { + runBlocking { coEvery { mockApi.sendSMSOTP(any(), any(), any(), any()) } returns successfulBaseResponse val response = impl.sms.send(mockk(relaxed = true)) assert(response is StytchResult.Success) @@ -89,7 +102,7 @@ internal class OTPImplTest { @Test fun `OTP SMS Authenticate delegates to the API and updates session`() = - runTest { + runBlocking { coEvery { mockApi.authenticateSMSOTP(any(), any(), any(), any(), any()) } returns successfulAuthResponse val response = impl.sms.authenticate(mockk(relaxed = true)) assert(response is StytchResult.Success) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/passwords/PasswordsImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/passwords/PasswordsImplTest.kt index b34c5e88c..747063a39 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/passwords/PasswordsImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/passwords/PasswordsImplTest.kt @@ -36,8 +36,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -86,7 +86,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl authenticate delegates to api`() = - runTest { + runBlocking { val mockkResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.authenticate(any(), any(), any(), any(), any(), any()) } returns mockkResponse val response = impl.authenticate(mockk(relaxed = true)) @@ -106,7 +106,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmailStart returns error if generateHashedCodeChallenge fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } throws RuntimeException("Test") val response = impl.resetByEmailStart(mockk(relaxed = true)) assert(response is StytchResult.Error) @@ -114,7 +114,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmailStart delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns PKCECodePair("", "") val mockkResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.resetByEmailStart(any(), any(), any(), any(), any(), any(), any()) } returns mockkResponse @@ -155,7 +155,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmail returns error if codeVerifier fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val response = impl.resetByEmail(mockk(relaxed = true)) assert(response is StytchResult.Error) @@ -163,7 +163,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmail delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("", "") val mockkResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.resetByEmail(any(), any(), any(), any(), any(), any()) } returns mockkResponse @@ -188,7 +188,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByExisting delegates to api`() = - runTest { + runBlocking { val mockkResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.resetByExisting(any(), any(), any(), any(), any(), any()) } returns mockkResponse val response = impl.resetByExisting(mockk(relaxed = true)) @@ -208,7 +208,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetBySession delegates to api`() = - runTest { + runBlocking { val mockkResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.resetBySession(any(), any()) } returns mockkResponse val response = impl.resetBySession(mockk(relaxed = true)) @@ -227,7 +227,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl strengthCheck delegates to api`() = - runTest { + runBlocking { val mockkResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.strengthCheck(any(), any()) } returns mockkResponse val response = impl.strengthCheck(mockk(relaxed = true)) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/rbac/RBACImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/rbac/RBACImplTest.kt index e9f952e6a..b52a38c68 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/rbac/RBACImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/rbac/RBACImplTest.kt @@ -23,12 +23,13 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date private val MOCK_RBAC_POLICY = RBACPolicy( @@ -107,6 +108,7 @@ internal class RBACImplTest { every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) mockkObject(StytchB2BClient) MockKAnnotations.init(this, true, true) + every { mockB2BSessionStorage.lastValidatedAt } returns Date(0) coEvery { StytchB2BClient.refreshBootstrapData() } just runs impl = RBACImpl( @@ -135,7 +137,7 @@ internal class RBACImplTest { @Test fun `allPermissions refreshes bootstrap data`() = - runTest { + runBlocking { mockLoggedOutMember() every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY impl.allPermissions() @@ -144,7 +146,7 @@ internal class RBACImplTest { @Test fun `allPermissions Calculates permissions for a logged-out member`() = - runTest { + runBlocking { mockLoggedOutMember() every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY val allPerms = impl.allPermissions() @@ -163,7 +165,7 @@ internal class RBACImplTest { @Test fun `allPermissions Calculates permissions for a default member`() = - runTest { + runBlocking { mockMemberWithRoles(listOf("default")) every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY val allPerms = impl.allPermissions() @@ -182,7 +184,7 @@ internal class RBACImplTest { @Test fun `allPermissions Calculates permissions for an admin member`() = - runTest { + runBlocking { mockMemberWithRoles(listOf("organization_admin")) every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY val allPerms = impl.allPermissions() @@ -210,7 +212,7 @@ internal class RBACImplTest { @Test fun `isAuthorizedSync uses cached data and does not update bootstrap`() = - runTest { + runBlocking { mockMemberWithRoles(listOf("default")) every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY_WITHOUT_DEFAULT_ROLE val isAuthorized = impl.isAuthorizedSync("documents", "read") @@ -247,7 +249,7 @@ internal class RBACImplTest { @Test fun `isAuthorized always refreshes the bootstrap data`() = - runTest { + runBlocking { mockLoggedOutMember() every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY impl.isAuthorized("documents", "read") @@ -256,7 +258,7 @@ internal class RBACImplTest { @Test fun `isAuthorized Calculates permissions for a logged-out member`() = - runTest { + runBlocking { mockLoggedOutMember() val isAuthorized = impl.isAuthorized("documents", "read") assert(!isAuthorized) @@ -264,7 +266,7 @@ internal class RBACImplTest { @Test fun `isAuthorized Calculates permissions for a default member`() = - runTest { + runBlocking { every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY mockMemberWithRoles(listOf("default")) val isAuthorizedToRead = impl.isAuthorized("documents", "read") @@ -275,7 +277,7 @@ internal class RBACImplTest { @Test fun `isAuthorized Calculates permissions for an admin member`() = - runTest { + runBlocking { every { StytchB2BClient.bootstrapData.rbacPolicy } returns MOCK_RBAC_POLICY mockMemberWithRoles(listOf("organization_admin")) val isAuthorizedToRead = impl.isAuthorized("documents", "read") diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/recoveryCodes/RecoveryCodesImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/recoveryCodes/RecoveryCodesImplTest.kt index e9b5c96e4..55048e490 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/recoveryCodes/RecoveryCodesImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/recoveryCodes/RecoveryCodesImplTest.kt @@ -1,5 +1,7 @@ package com.stytch.sdk.b2b.recoveryCodes +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor import com.stytch.sdk.b2b.RecoveryCodesGetResponse import com.stytch.sdk.b2b.RecoveryCodesRecoverResponse import com.stytch.sdk.b2b.RecoveryCodesRotateResponse @@ -28,8 +30,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -39,6 +41,12 @@ internal class RecoveryCodesImplTest { @MockK private lateinit var mockApi: StytchB2BApi.RecoveryCodes + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockSharedPreferencesEditor: Editor + private lateinit var spiedSessionStorage: B2BSessionStorage private lateinit var impl: RecoveryCodes @@ -57,6 +65,12 @@ internal class RecoveryCodesImplTest { mockkObject(SessionAutoUpdater) mockkStatic("com.stytch.sdk.b2b.extensions.StytchResultExtKt") every { SessionAutoUpdater.startSessionUpdateJob(any(), any(), any()) } just runs + every { mockSharedPreferences.edit() } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferences.getLong(any(), any()) } returns 0L + every { mockSharedPreferencesEditor.apply() } just runs + StorageHelper.sharedPreferences = mockSharedPreferences spiedSessionStorage = spyk(B2BSessionStorage(StorageHelper, TestScope()), recordPrivateCalls = true) impl = RecoveryCodesImpl( @@ -75,7 +89,7 @@ internal class RecoveryCodesImplTest { @Test fun `RecoveryCodes get delegates to the api`() = - runTest { + runBlocking { coEvery { mockApi.get() } returns successfulGetResponse val response = impl.get() assert(response is StytchResult.Success) @@ -92,7 +106,7 @@ internal class RecoveryCodesImplTest { @Test fun `RecoveryCodes rotate delegates to the api`() = - runTest { + runBlocking { coEvery { mockApi.rotate() } returns successfulRotateResponse val response = impl.rotate() assert(response is StytchResult.Success) @@ -109,7 +123,7 @@ internal class RecoveryCodesImplTest { @Test fun `RecoveryCodes recover delegates to the api and updates the session`() = - runTest { + runBlocking { coEvery { mockApi.recover(any(), any(), any(), any()) } returns successfulRecoverResponse val response = impl.recover(mockk(relaxed = true)) assert(response is StytchResult.Success) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/scim/SCIMImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/scim/SCIMImplTest.kt index b47bb819c..512989af8 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/scim/SCIMImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/scim/SCIMImplTest.kt @@ -29,8 +29,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -86,7 +86,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl createConnection delegates to api`() = - runTest { + runBlocking { val response = impl.createConnection(mockk(relaxed = true)) assert(response is StytchResult.Success) coVerify { mockApi.createConnection(any(), any()) } @@ -101,7 +101,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl updateConnection delegates to api`() = - runTest { + runBlocking { val response = impl.updateConnection(mockk(relaxed = true)) assert(response is StytchResult.Success) coVerify { mockApi.updateConnection(any(), any(), any(), any()) } @@ -116,7 +116,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl deleteConnection delegates to api`() = - runTest { + runBlocking { val response = impl.deleteConnection("connection-id") assert(response is StytchResult.Success) coVerify { mockApi.deleteConection(any()) } @@ -131,7 +131,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl getConnection delegates to api`() = - runTest { + runBlocking { val response = impl.getConnection() assert(response is StytchResult.Success) coVerify { mockApi.getConnection() } @@ -146,7 +146,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl getConnectionGroups delegates to api`() = - runTest { + runBlocking { val response = impl.getConnectionGroups(mockk(relaxed = true)) assert(response is StytchResult.Success) coVerify { mockApi.getConnectionGroups(any(), any()) } @@ -161,7 +161,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl rotateStart delegates to api`() = - runTest { + runBlocking { val response = impl.rotateStart("connection-id") assert(response is StytchResult.Success) coVerify { mockApi.rotateStart(any()) } @@ -176,7 +176,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl rotateCancel delegates to api`() = - runTest { + runBlocking { val response = impl.rotateCancel("connection-id") assert(response is StytchResult.Success) coVerify { mockApi.rotateCancel(any()) } @@ -191,7 +191,7 @@ internal class SCIMImplTest { @Test fun `SCIMImpl rotateComplete delegates to api`() = - runTest { + runBlocking { val response = impl.rotateComplete("connection-id") assert(response is StytchResult.Success) coVerify { mockApi.rotateComplete(any()) } diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/searchManager/SearchManagerImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/searchManager/SearchManagerImplTest.kt index b51966cc5..4a0bf4af5 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/searchManager/SearchManagerImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/searchManager/SearchManagerImplTest.kt @@ -14,8 +14,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -45,7 +45,7 @@ internal class SearchManagerImplTest { @Test fun `SearchManager searchOrganizations delegates to the api`() = - runTest { + runBlocking { coEvery { mockApi.searchOrganizations(any()) } returns mockk(relaxed = true) impl.searchOrganization(mockk(relaxed = true)) coVerify { mockApi.searchOrganizations(any()) } @@ -61,7 +61,7 @@ internal class SearchManagerImplTest { @Test fun `SearchManager searchMembers delegates to the api`() = - runTest { + runBlocking { coEvery { mockApi.searchMembers(any(), any()) } returns mockk(relaxed = true) impl.searchMember(mockk(relaxed = true)) coVerify { mockApi.searchMembers(any(), any()) } diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionStorageTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionStorageTest.kt index e6e0c49ce..2378e9b9f 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionStorageTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionStorageTest.kt @@ -1,6 +1,8 @@ package com.stytch.sdk.b2b.sessions import com.stytch.sdk.b2b.network.models.B2BSessionData +import com.stytch.sdk.common.PREFERENCES_NAME_LAST_VALIDATED_AT +import com.stytch.sdk.common.PREFERENCES_NAME_MEMBER_SESSION_DATA import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_JWT import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_TOKEN import com.stytch.sdk.common.StorageHelper @@ -31,6 +33,10 @@ internal class B2BSessionStorageTest { mockkStatic(KeyStore::class) every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) MockKAnnotations.init(this, true, true) + every { mockStorageHelper.loadValue(any()) } returns "{}" + every { mockStorageHelper.saveValue(any(), any()) } just runs + every { mockStorageHelper.saveLong(any(), any()) } just runs + every { mockStorageHelper.getLong(any()) } returns 0 storage = B2BSessionStorage(mockStorageHelper, TestScope()) } @@ -81,7 +87,8 @@ internal class B2BSessionStorageTest { sessionJwt = "mySessionJwt", session = mockedSessionData, ) - assert(storage.memberSession == mockedSessionData) + verify { mockStorageHelper.saveValue(PREFERENCES_NAME_MEMBER_SESSION_DATA, any()) } + verify { mockStorageHelper.saveLong(PREFERENCES_NAME_LAST_VALIDATED_AT, any()) } } @Test diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionsImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionsImplTest.kt index 5f9d648c6..742f445ae 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionsImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/sessions/B2BSessionsImplTest.kt @@ -29,12 +29,13 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date internal class B2BSessionsImplTest { @MockK @@ -63,6 +64,9 @@ internal class B2BSessionsImplTest { every { mockSessionStorage.memberFlow } returns mockk(relaxed = true) every { mockSessionStorage.sessionFlow } returns mockk(relaxed = true) every { mockSessionStorage.organizationFlow } returns mockk(relaxed = true) + every { mockSessionStorage.lastValidatedAtFlow } returns mockk(relaxed = true) + every { mockSessionStorage.memberSession } returns mockk(relaxed = true) + every { mockSessionStorage.lastValidatedAt } returns Date(0L) impl = B2BSessionsImpl( externalScope = TestScope(), @@ -102,7 +106,7 @@ internal class B2BSessionsImplTest { @Test fun `SessionsImpl authenticate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.authenticate(any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) assert(response is StytchResult.Success) @@ -120,7 +124,7 @@ internal class B2BSessionsImplTest { @Test fun `SessionsImpl revoke delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns mockk() impl.revoke() coVerify { mockApi.revoke() } @@ -128,7 +132,7 @@ internal class B2BSessionsImplTest { @Test fun `SessionsImpl revoke does not revoke a local session if a network error occurs and forceClear is not true`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns StytchResult.Error(mockk(relaxed = true)) impl.revoke() verify(exactly = 0) { mockSessionStorage.revoke() } @@ -136,7 +140,7 @@ internal class B2BSessionsImplTest { @Test fun `SessionsImpl revoke does revoke a local session if a network error occurs and forceClear is true`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns StytchResult.Error(mockk(relaxed = true)) impl.revoke(B2BSessions.RevokeParams(true)) verify { mockSessionStorage.revoke() } @@ -144,7 +148,7 @@ internal class B2BSessionsImplTest { @Test fun `SessionsImpl revoke returns error if sessionStorage revoke fails`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns StytchResult.Success(mockk(relaxed = true)) every { mockSessionStorage.revoke() } throws RuntimeException("Test") val result = impl.revoke(B2BSessions.RevokeParams(true)) @@ -184,7 +188,7 @@ internal class B2BSessionsImplTest { @Test fun `SessionsImpl exchange delegates to the api`() = - runTest { + runBlocking { coEvery { mockApi.exchange(any(), any()) } returns successfulExchangeResponse impl.exchange(B2BSessions.ExchangeParameters(organizationId = "test-123", sessionDurationMinutes = 30)) coVerify(exactly = 1) { mockApi.exchange(any(), any()) } diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/sso/SSOImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/sso/SSOImplTest.kt index bc0f96000..9813b2b60 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/sso/SSOImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/sso/SSOImplTest.kt @@ -34,8 +34,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -84,7 +84,7 @@ internal class SSOImplTest { @Test fun `SSO authenticate returns error if codeverifier fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val response = impl.authenticate(mockk(relaxed = true)) assert(response is StytchResult.Error) @@ -92,7 +92,7 @@ internal class SSOImplTest { @Test fun `SSO authenticate delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("", "") val mockResponse = StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.authenticate(any(), any(), any(), any()) } returns mockResponse @@ -113,7 +113,7 @@ internal class SSOImplTest { @Test fun `SSO getConnections delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.getConnections() } returns mockk(relaxed = true) impl.getConnections() coVerify { mockApi.getConnections() } @@ -129,7 +129,7 @@ internal class SSOImplTest { @Test fun `SSO deleteConnection delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.deleteConnection(any()) } returns mockk(relaxed = true) val connectionId = "my-connection-id" impl.deleteConnection(connectionId) @@ -146,7 +146,7 @@ internal class SSOImplTest { @Test fun `SSO saml create delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.samlCreateConnection(any()) } returns mockk(relaxed = true) val parameters = SSO.SAML.CreateParameters(displayName = "my cool display name") impl.saml.createConnection(parameters) @@ -163,7 +163,7 @@ internal class SSOImplTest { @Test fun `SSO saml update delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.samlUpdateConnection(any()) } returns mockk(relaxed = true) val parameters = SSO.SAML.UpdateParameters(connectionId = "connection-id") impl.saml.updateConnection(parameters) @@ -181,7 +181,7 @@ internal class SSOImplTest { @Test fun `SSO saml updateConnectionByUrl delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.samlUpdateByUrl(any(), any()) } returns mockk(relaxed = true) val parameters = SSO.SAML.UpdateByURLParameters( @@ -208,7 +208,7 @@ internal class SSOImplTest { @Test fun `SSO saml deleteVerificationCertificate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.samlDeleteVerificationCertificate(any(), any()) } returns mockk(relaxed = true) val parameters = SSO.SAML.DeleteVerificationCertificateParameters( @@ -239,7 +239,7 @@ internal class SSOImplTest { @Test fun `SSO oidc create delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.oidcCreateConnection(any()) } returns mockk(relaxed = true) val parameters = SSO.OIDC.CreateParameters(displayName = "my cool display name") impl.oidc.createConnection(parameters) @@ -256,7 +256,7 @@ internal class SSOImplTest { @Test fun `SSO oidc update delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.oidcUpdateConnection(any()) } returns mockk(relaxed = true) val parameters = SSO.OIDC.UpdateParameters(connectionId = "connection-id") impl.oidc.updateConnection(parameters) diff --git a/source/sdk/src/test/java/com/stytch/sdk/b2b/totp/TOTPImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/b2b/totp/TOTPImplTest.kt index c4c281ee7..5b140d80d 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/b2b/totp/TOTPImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/b2b/totp/TOTPImplTest.kt @@ -1,5 +1,7 @@ package com.stytch.sdk.b2b.totp +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor import com.stytch.sdk.b2b.TOTPAuthenticateResponse import com.stytch.sdk.b2b.TOTPCreateResponse import com.stytch.sdk.b2b.extensions.launchSessionUpdater @@ -26,8 +28,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -37,6 +39,12 @@ internal class TOTPImplTest { @MockK private lateinit var mockApi: StytchB2BApi.TOTP + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockSharedPreferencesEditor: Editor + private lateinit var spiedSessionStorage: B2BSessionStorage private lateinit var impl: TOTP @@ -54,6 +62,12 @@ internal class TOTPImplTest { mockkObject(SessionAutoUpdater) mockkStatic("com.stytch.sdk.b2b.extensions.StytchResultExtKt") every { SessionAutoUpdater.startSessionUpdateJob(any(), any(), any()) } just runs + every { mockSharedPreferences.edit() } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferences.getLong(any(), any()) } returns 0L + every { mockSharedPreferencesEditor.apply() } just runs + StorageHelper.sharedPreferences = mockSharedPreferences spiedSessionStorage = spyk(B2BSessionStorage(StorageHelper, TestScope()), recordPrivateCalls = true) impl = TOTPImpl( @@ -72,7 +86,7 @@ internal class TOTPImplTest { @Test fun `TOTP create delegates to the API`() = - runTest { + runBlocking { coEvery { mockApi.create(any(), any(), any()) } returns successfulCreateResponse val response = impl.create(TOTP.CreateParameters("", "", 30)) assert(response is StytchResult.Success) @@ -89,7 +103,7 @@ internal class TOTPImplTest { @Test fun `TOTP Authenticate delegates to the API and updates session`() = - runTest { + runBlocking { coEvery { mockApi.authenticate(any(), any(), any(), any(), any(), any()) } returns successfulAuthResponse val response = impl.authenticate(mockk(relaxed = true)) assert(response is StytchResult.Success) diff --git a/source/sdk/src/test/java/com/stytch/sdk/common/dfp/DFPImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/common/dfp/DFPImplTest.kt index 80e3c04fe..f10e1295d 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/common/dfp/DFPImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/common/dfp/DFPImplTest.kt @@ -10,8 +10,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -42,7 +42,7 @@ internal class DFPImplTest { @Test fun `getTelemetryId delegates to provider`() = - runTest { + runBlocking { impl.getTelemetryId() coVerify(exactly = 1) { dfpProvider.getTelemetryId() } } diff --git a/source/sdk/src/test/java/com/stytch/sdk/common/events/EventsImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/common/events/EventsImplTest.kt index 6eef4ec9c..057ba1bcf 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/common/events/EventsImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/common/events/EventsImplTest.kt @@ -12,8 +12,8 @@ import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -58,7 +58,7 @@ internal class EventsImplTest { @Test fun `EventsImpl logEvent delegates to api`() = - runTest { + runBlocking { coEvery { mockEventsAPI.logEvent(any(), any(), any(), any(), any(), any(), any()) } returns mockk() val mockDetails = mapOf("test-key" to "test value") impl.logEvent("test-event", mockDetails) diff --git a/source/sdk/src/test/java/com/stytch/sdk/common/network/SafeApiCallTest.kt b/source/sdk/src/test/java/com/stytch/sdk/common/network/SafeApiCallTest.kt index f225a9873..c4075a71c 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/common/network/SafeApiCallTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/common/network/SafeApiCallTest.kt @@ -7,13 +7,13 @@ import com.stytch.sdk.utils.API_ERROR_RESPONSE_STRING import com.stytch.sdk.utils.createHttpExceptionReturningString import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.Test internal class SafeApiCallTest { @Test fun `A successful request returns StytchResult_Success`() = - runTest { + runBlocking { val result = safeApiCall({}) { mockk(relaxed = true) @@ -23,7 +23,7 @@ internal class SafeApiCallTest { @Test fun `An unsuccessful request returns StytchResult_Error`() = - runTest { + runBlocking { val result = safeApiCall({}) { mockk { diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt index a9546a5ec..9dad0b91d 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import android.net.Uri import com.google.android.recaptcha.Recaptcha +import com.squareup.moshi.Moshi import com.stytch.sdk.common.DeeplinkHandledStatus import com.stytch.sdk.common.DeviceInfo import com.stytch.sdk.common.EncryptionManager @@ -17,9 +18,11 @@ import com.stytch.sdk.common.errors.StytchInternalError import com.stytch.sdk.common.errors.StytchSDKNotConfiguredError import com.stytch.sdk.common.extensions.getDeviceInfo import com.stytch.sdk.common.pkcePairManager.PKCEPairManager +import com.stytch.sdk.common.utils.SHORT_FORM_DATE_FORMATTER import com.stytch.sdk.consumer.extensions.launchSessionUpdater import com.stytch.sdk.consumer.network.StytchApi import com.stytch.sdk.consumer.network.models.AuthData +import com.stytch.sdk.consumer.network.models.SessionData import io.mockk.MockKAnnotations import io.mockk.clearAllMocks import io.mockk.coEvery @@ -41,12 +44,12 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date internal class StytchClientTest { private var mContextMock = mockk(relaxed = true) @@ -86,7 +89,10 @@ internal class StytchClientTest { MockKAnnotations.init(this, true, true) coEvery { Recaptcha.getClient(any(), any()) } returns Result.success(mockk(relaxed = true)) every { StorageHelper.initialize(any()) } just runs - every { StorageHelper.loadValue(any()) } returns "some-value" + every { StorageHelper.loadValue(any()) } returns "{}" + every { StorageHelper.saveValue(any(), any()) } just runs + every { StorageHelper.saveLong(any(), any()) } just runs + every { StorageHelper.getLong(any()) } returns 0 every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns mockk() every { mockPKCEPairManager.getPKCECodePair() } returns mockk() coEvery { StytchApi.getBootstrapData() } returns StytchResult.Error(mockk()) @@ -167,17 +173,41 @@ internal class StytchClientTest { StytchClient.configure(mContextMock, "") coVerify(exactly = 0) { StytchApi.Sessions.authenticate() } verify(exactly = 0) { mockResponse.launchSessionUpdater(any(), any()) } - // yes session data == yes authentication/updater - every { StorageHelper.loadValue(any()) } returns "some-session-data" + // yes session data, but expired, no authentication/updater + val mockExpiredSession = + mockk(relaxed = true) { + every { expiresAt } returns SHORT_FORM_DATE_FORMATTER.format(Date(0L)) + } + val mockExpiredSessionJSON = + Moshi + .Builder() + .build() + .adapter(SessionData::class.java) + .lenient() + .toJson(mockExpiredSession) + every { StorageHelper.loadValue(any()) } returns mockExpiredSessionJSON StytchClient.configure(mContextMock, "") - coVerify(exactly = 1) { StytchApi.Sessions.authenticate() } - verify(exactly = 1) { mockResponse.launchSessionUpdater(any(), any()) } + coVerify(exactly = 0) { StytchApi.Sessions.authenticate() } + verify(exactly = 0) { mockResponse.launchSessionUpdater(any(), any()) } + // yes session data, and valid, yes authentication/updater + val mockValidSession = + mockk(relaxed = true) { + every { expiresAt } returns SHORT_FORM_DATE_FORMATTER.format(Date(Date().time + 1000)) + } + val mockValidSessionJSON = + Moshi + .Builder() + .build() + .adapter(SessionData::class.java) + .lenient() + .toJson(mockValidSession) + every { StorageHelper.loadValue(any()) } returns mockValidSessionJSON } } @Test fun `should report the initialization state after configuration and initialization is complete`() { - runTest { + runBlocking { val mockResponse: StytchResult = mockk { every { launchSessionUpdater(any(), any()) } just runs diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/biometrics/BiometricsImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/biometrics/BiometricsImplTest.kt index 9550e3a93..506b15cc8 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/biometrics/BiometricsImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/biometrics/BiometricsImplTest.kt @@ -37,8 +37,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -112,7 +112,7 @@ internal class BiometricsImplTest { @Test fun `register returns correct error if insecure keystore and allowFallbackToCleartext is false`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns false val mockRegisterParameters: Biometrics.RegisterParameters = mockk(relaxed = true) { @@ -125,7 +125,7 @@ internal class BiometricsImplTest { @Test fun `register returns correct error if no session is found`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.preferenceExists(any()) } returns false every { @@ -138,7 +138,7 @@ internal class BiometricsImplTest { @Test fun `register removes existing registration (local and remote) if found`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.loadValue(any()) } returns "biometric-registration-id" every { mockStorageHelper.preferenceExists(any()) } returns true @@ -163,7 +163,7 @@ internal class BiometricsImplTest { @Test fun `register removes existing registration (local only) if unexpected exception occurs`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.loadValue(any()) } returns null every { mockStorageHelper.preferenceExists(any()) } returns true @@ -183,7 +183,7 @@ internal class BiometricsImplTest { @Test fun `register returns correct error if biometrics fail`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.preferenceExists(any()) } returns false every { mockSessionStorage.ensureSessionIsValidOrThrow() } just runs @@ -197,7 +197,7 @@ internal class BiometricsImplTest { @Test fun `register returns correct error if keys could not be generated`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.preferenceExists(any()) } returns false every { mockSessionStorage.ensureSessionIsValidOrThrow() } just runs @@ -214,7 +214,7 @@ internal class BiometricsImplTest { @Test fun `register returns correct error if registerStart fails`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.preferenceExists(any()) } returns false every { mockSessionStorage.ensureSessionIsValidOrThrow() } just runs @@ -236,7 +236,7 @@ internal class BiometricsImplTest { @Test fun `register returns correct error if challenge signing fails`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.preferenceExists(any()) } returns false every { mockSessionStorage.ensureSessionIsValidOrThrow() } just runs @@ -263,7 +263,7 @@ internal class BiometricsImplTest { @Test fun `register returns success if everything succeeds and saves required preferences`() = - runTest { + runBlocking { every { mockStorageHelper.checkIfKeysetIsUsingKeystore() } returns true every { mockStorageHelper.preferenceExists(any()) } returns false every { mockSessionStorage.ensureSessionIsValidOrThrow() } just runs @@ -306,7 +306,7 @@ internal class BiometricsImplTest { @Test fun `authenticate wraps unexpected exceptions in StytchResult Error class`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } throws RuntimeException("Testing") val result = impl.authenticate(mockk(relaxed = true)) require(result is StytchResult.Error) @@ -315,7 +315,7 @@ internal class BiometricsImplTest { @Test fun `authenticate returns correct error if biometrics are not available`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } returns false every { mockStorageHelper.loadValue(any()) } returns null val result = impl.authenticate(mockk(relaxed = true)) @@ -325,7 +325,7 @@ internal class BiometricsImplTest { @Test fun `authenticate returns correct error if biometrics fails`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } returns true every { mockBiometricsProvider.ensureSecretKeyIsAvailable(any()) } just runs every { @@ -342,7 +342,7 @@ internal class BiometricsImplTest { @Test fun `authenticate returns correct error if public key cannot be derived from private key`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } returns true every { mockBiometricsProvider.ensureSecretKeyIsAvailable(any()) } just runs every { @@ -362,7 +362,7 @@ internal class BiometricsImplTest { @Test fun `authenticate returns correct error if authenticateStart fails`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } returns true every { mockBiometricsProvider.ensureSecretKeyIsAvailable(any()) } just runs every { @@ -387,7 +387,7 @@ internal class BiometricsImplTest { @Test fun `authenticate returns correct error if challenge signing fails`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } returns true every { mockBiometricsProvider.ensureSecretKeyIsAvailable(any()) } just runs every { @@ -417,7 +417,7 @@ internal class BiometricsImplTest { @Test fun `authenticate returns success if everything succeeds`() = - runTest { + runBlocking { every { mockStorageHelper.preferenceExists(any()) } returns true every { mockBiometricsProvider.ensureSecretKeyIsAvailable(any()) } just runs every { @@ -471,7 +471,7 @@ internal class BiometricsImplTest { @Test fun `removeRegistration delegates to storageHelper and deletes registration from user as appropriate`() = - runTest { + runBlocking { every { mockStorageHelper.loadValue(any()) } returns "lastUsedRegistrationId" every { mockStorageHelper.deletePreference(any()) } returns true coEvery { mockUserManagerApi.deleteBiometricRegistrationById(any()) } returns mockk(relaxed = true) diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/crypto/CryptoWalletImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/crypto/CryptoWalletImplTest.kt index c08c04ecc..123543808 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/crypto/CryptoWalletImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/crypto/CryptoWalletImplTest.kt @@ -24,8 +24,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -64,7 +64,7 @@ internal class CryptoWalletImplTest { @Test fun `CryptoWallet authenticateStart with no active session delegates to appropriate api method`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.authenticateStartPrimary(any(), any()) } returns mockk(relaxed = true) impl.authenticateStart(mockk(relaxed = true)) @@ -73,7 +73,7 @@ internal class CryptoWalletImplTest { @Test fun `CryptoWallet authenticateStart with an active session delegates to appropriate api method`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.authenticateStartSecondary(any(), any()) } returns mockk(relaxed = true) impl.authenticateStart(mockk(relaxed = true)) @@ -91,7 +91,7 @@ internal class CryptoWalletImplTest { @Test fun `CryptoWallet authenticate delegates to appropriate api method`() = - runTest { + runBlocking { coEvery { mockApi.authenticate(any(), any(), any(), any()) } returns successfulAuthResponse impl.authenticate(mockk(relaxed = true)) coVerify(exactly = 1) { mockApi.authenticate(any(), any(), any(), any()) } diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt index 60d886f48..e3807902b 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt @@ -29,8 +29,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -84,7 +84,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl authenticate returns error if codeverifier fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val response = impl.authenticate(authParameters) assert(response is StytchResult.Error) @@ -92,7 +92,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl authenticate delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("", "") coEvery { mockApi.authenticate(any(), any(), any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) @@ -112,7 +112,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email loginOrCreate returns error if generateCodeChallenge fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } throws RuntimeException("Test") val response = impl.email.loginOrCreate(emailMagicLinkParameters) assert(response is StytchResult.Error) @@ -120,7 +120,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email loginOrCreate delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns PKCECodePair("", "") coEvery { mockApi.loginOrCreate(any(), any(), any(), any(), any(), any(), any()) @@ -138,7 +138,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email send with active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendSecondary(any(), any(), any(), any(), any(), any(), any(), any()) @@ -156,7 +156,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email send with no active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendPrimary(any(), any(), any(), any(), any(), any(), any(), any()) diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/network/StytchApiTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/network/StytchApiTest.kt index 524135b03..f3181b7f8 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/network/StytchApiTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/network/StytchApiTest.kt @@ -24,7 +24,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test @@ -91,7 +91,7 @@ internal class StytchApiTest { @Test fun `StytchApi MagicLinks Email loginOrCreate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.loginOrCreateUserByEmail(any()) } returns mockk(relaxed = true) StytchApi.MagicLinks.Email.loginOrCreate("", null, null, "", null, null) @@ -100,7 +100,7 @@ internal class StytchApiTest { @Test fun `StytchApi MagicLinks Email sendPrimary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendEmailMagicLinkPrimary(any()) } returns mockk(relaxed = true) StytchApi.MagicLinks.Email.sendPrimary("", null, null, null, null, null, null, null) @@ -109,7 +109,7 @@ internal class StytchApiTest { @Test fun `StytchApi MagicLinks Email sendSecondar calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendEmailMagicLinkSecondary(any()) } returns mockk(relaxed = true) StytchApi.MagicLinks.Email.sendSecondary("", null, null, null, null, null, null, null) @@ -118,7 +118,7 @@ internal class StytchApiTest { @Test fun `StytchApi MagicLinks Email authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.authenticate(any()) } returns mockk(relaxed = true) StytchApi.MagicLinks.Email.authenticate("", 30, "") @@ -127,7 +127,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP loginOrCreateByOTPWithSMS calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.loginOrCreateUserByOTPWithSMS(any()) } returns mockk(relaxed = true) StytchApi.OTP.loginOrCreateByOTPWithSMS("", 30) @@ -136,7 +136,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP sendOTPWithSMSPrimary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendOTPWithSMSPrimary(any()) } returns mockk(relaxed = true) StytchApi.OTP.sendOTPWithSMSPrimary("", 30) @@ -145,7 +145,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP sendOTPWithSMSSecondary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendOTPWithSMSSecondary(any()) } returns mockk(relaxed = true) StytchApi.OTP.sendOTPWithSMSSecondary("", 30) @@ -154,7 +154,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP loginOrCreateUserByOTPWithWhatsApp calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.loginOrCreateUserByOTPWithWhatsApp(any()) } returns mockk(relaxed = true) StytchApi.OTP.loginOrCreateUserByOTPWithWhatsApp("", 30) @@ -163,7 +163,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP sendByOTPWithWhatsAppPrimary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendOTPWithWhatsAppPrimary(any()) } returns mockk(relaxed = true) StytchApi.OTP.sendOTPWithWhatsAppPrimary("", 30) @@ -172,7 +172,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP sendByOTPWithWhatsAppSecondary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendOTPWithWhatsAppSecondary(any()) } returns mockk(relaxed = true) StytchApi.OTP.sendOTPWithWhatsAppSecondary("", 30) @@ -181,7 +181,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP loginOrCreateUserByOTPWithEmail calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.loginOrCreateUserByOTPWithEmail(any()) } returns mockk(relaxed = true) StytchApi.OTP.loginOrCreateUserByOTPWithEmail("", 30, "", "") @@ -190,7 +190,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP sendOTPWithEmailPrimary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendOTPWithEmailPrimary(any()) } returns mockk(relaxed = true) StytchApi.OTP.sendOTPWithEmailPrimary("", 30, null, null) @@ -199,7 +199,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP sendOTPWithEmailSecondary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.sendOTPWithEmailSecondary(any()) } returns mockk(relaxed = true) StytchApi.OTP.sendOTPWithEmailSecondary("", 30, null, null) @@ -208,7 +208,7 @@ internal class StytchApiTest { @Test fun `StytchApi OTP authenticateWithOTP calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.authenticateWithOTP(any()) } returns mockk(relaxed = true) StytchApi.OTP.authenticateWithOTP("", "") @@ -217,7 +217,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.authenticateWithPasswords(any()) } returns mockk(relaxed = true) StytchApi.Passwords.authenticate("", "", 30) @@ -226,7 +226,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords create calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.passwords(any()) } returns mockk(relaxed = true) StytchApi.Passwords.create("", "", 30) @@ -235,7 +235,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords resetByEmailStart calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.resetByEmailStart(any()) } returns mockk(relaxed = true) StytchApi.Passwords.resetByEmailStart("", "", "", 30, "", 30, "") @@ -244,7 +244,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords resetByEmail calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.resetByEmail(any()) } returns mockk(relaxed = true) StytchApi.Passwords.resetByEmail("", "", 30, "") @@ -253,7 +253,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords resetBySession calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.resetBySession(any()) } returns mockk(relaxed = true) StytchApi.Passwords.resetBySession(password = "", sessionDurationMinutes = 30) @@ -262,7 +262,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords resetByExisting calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.resetByExistingPassword(any()) } returns mockk(relaxed = true) StytchApi.Passwords.resetByExisting( @@ -276,7 +276,7 @@ internal class StytchApiTest { @Test fun `StytchApi Passwords strengthCheck calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.strengthCheck(any()) } returns mockk(relaxed = true) StytchApi.Passwords.strengthCheck("", "") @@ -285,7 +285,7 @@ internal class StytchApiTest { @Test fun `StytchApi Sessions authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.authenticateSessions(any()) } returns mockk(relaxed = true) StytchApi.Sessions.authenticate(30) @@ -294,7 +294,7 @@ internal class StytchApiTest { @Test fun `StytchApi Sessions revoke calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.revokeSessions() } returns mockk(relaxed = true) StytchApi.Sessions.revoke() @@ -303,7 +303,7 @@ internal class StytchApiTest { @Test fun `StytchApi Biometrics registerStart calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.biometricsRegisterStart(any()) } returns mockk(relaxed = true) StytchApi.Biometrics.registerStart("") @@ -312,7 +312,7 @@ internal class StytchApiTest { @Test fun `StytchApi Biometrics register calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.biometricsRegister(any()) } returns mockk(relaxed = true) StytchApi.Biometrics.register("", "", 30) @@ -321,7 +321,7 @@ internal class StytchApiTest { @Test fun `StytchApi Biometrics authenticateStart calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.biometricsAuthenticateStart(any()) } returns mockk(relaxed = true) StytchApi.Biometrics.authenticateStart("") @@ -330,7 +330,7 @@ internal class StytchApiTest { @Test fun `StytchApi Biometrics authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.biometricsAuthenticate(any()) } returns mockk(relaxed = true) StytchApi.Biometrics.authenticate("", "", 30) @@ -339,7 +339,7 @@ internal class StytchApiTest { @Test fun `StytchApi User getUser calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.getUser() } returns mockk(relaxed = true) StytchApi.UserManagement.getUser() @@ -348,7 +348,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deleteEmailById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deleteEmailById("emailAddressId") } returns mockk(relaxed = true) StytchApi.UserManagement.deleteEmailById("emailAddressId") @@ -357,7 +357,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deletePhoneNumberById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deletePhoneNumberById("phoneNumberId") } returns mockk(relaxed = true) StytchApi.UserManagement.deletePhoneNumberById("phoneNumberId") @@ -366,7 +366,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deleteBiometricRegistrationById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deleteBiometricRegistrationById("biometricsRegistrationId") @@ -377,7 +377,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deleteCryptoWalletById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deleteCryptoWalletById(any()) @@ -388,7 +388,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deleteWebAuthnById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deleteWebAuthnById(any()) @@ -399,7 +399,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deleteTOTPById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deleteTOTPById(any()) @@ -410,7 +410,7 @@ internal class StytchApiTest { @Test fun `StytchApi User deleteOAuthById calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.deleteOAuthById(any()) @@ -421,7 +421,7 @@ internal class StytchApiTest { @Test fun `StytchApi User updateUser calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.updateUser(any()) } returns mockk(relaxed = true) StytchApi.UserManagement.updateUser(mockk(), mockk()) @@ -430,7 +430,7 @@ internal class StytchApiTest { @Test fun `StytchApi OAuth authenticateWithGoogleIdToken calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.authenticateWithGoogleIdToken(any()) } returns mockk(relaxed = true) StytchApi.OAuth.authenticateWithGoogleIdToken( @@ -451,7 +451,7 @@ internal class StytchApiTest { @Test fun `StytchApi OAuth authenticateWithThirdPartyToken calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.authenticateWithThirdPartyToken(any()) } returns mockk(relaxed = true) StytchApi.OAuth.authenticateWithThirdPartyToken( @@ -472,7 +472,7 @@ internal class StytchApiTest { @Test fun `StytchApi Bootstrap getBootstrapData calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true every { StytchApi.publicToken } returns "mock-public-token" coEvery { StytchApi.apiService.getBootstrapData("mock-public-token") } returns mockk(relaxed = true) @@ -482,7 +482,7 @@ internal class StytchApiTest { @Test fun `StytchApi User searchUsers calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.searchUsers(any()) } returns mockk(relaxed = true) StytchApi.UserManagement.searchUsers("user@domain.com") @@ -497,7 +497,7 @@ internal class StytchApiTest { @Test fun `StytchApi Webauthn registerStart calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.webAuthnRegisterStart(mockk(relaxed = true)) } returns mockk(relaxed = true) StytchApi.WebAuthn.registerStart("") @@ -506,7 +506,7 @@ internal class StytchApiTest { @Test fun `StytchApi Webauthn register calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.webAuthnRegister(mockk(relaxed = true)) } returns mockk(relaxed = true) StytchApi.WebAuthn.register("") @@ -515,7 +515,7 @@ internal class StytchApiTest { @Test fun `StytchApi Webauthn webAuthnAuthenticateStartPrimary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.webAuthnAuthenticateStartPrimary(mockk(relaxed = true)) @@ -526,7 +526,7 @@ internal class StytchApiTest { @Test fun `StytchApi Webauthn webAuthnAuthenticateStartSecondary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.webAuthnAuthenticateStartSecondary(mockk(relaxed = true)) @@ -537,7 +537,7 @@ internal class StytchApiTest { @Test fun `StytchApi Webauthn webAuthnAuthenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.webAuthnAuthenticate(mockk(relaxed = true)) } returns mockk(relaxed = true) StytchApi.WebAuthn.authenticate("", 30) @@ -546,7 +546,7 @@ internal class StytchApiTest { @Test fun `StytchApi Webauthn webAuthnUpdate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.webAuthnUpdate(any(), any()) } returns mockk(relaxed = true) StytchApi.WebAuthn.update("", "my new name") @@ -555,7 +555,7 @@ internal class StytchApiTest { @Test fun `StytchApi Crypto authenticateStartPrimary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.cryptoWalletAuthenticateStartPrimary(any()) } returns mockk(relaxed = true) StytchApi.Crypto.authenticateStartPrimary( @@ -567,7 +567,7 @@ internal class StytchApiTest { @Test fun `StytchApi Crypto authenticateStartSecondary calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.cryptoWalletAuthenticateStartSecondary(any()) } returns mockk(relaxed = true) StytchApi.Crypto.authenticateStartSecondary( @@ -579,7 +579,7 @@ internal class StytchApiTest { @Test fun `StytchApi Crypto authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.cryptoWalletAuthenticate(any()) } returns mockk(relaxed = true) StytchApi.Crypto.authenticate( @@ -593,7 +593,7 @@ internal class StytchApiTest { @Test fun `StytchApi TOTP create calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.totpsCreate(any()) } returns mockk(relaxed = true) StytchApi.TOTP.create(expirationMinutes = 5) @@ -602,7 +602,7 @@ internal class StytchApiTest { @Test fun `StytchApi TOTP authenticate calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.totpsAuthenticate(any()) } returns mockk(relaxed = true) StytchApi.TOTP.authenticate( @@ -614,7 +614,7 @@ internal class StytchApiTest { @Test fun `StytchApi TOTP recoveryCodes calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.totpsRecoveryCodes() } returns mockk(relaxed = true) StytchApi.TOTP.recoveryCodes() @@ -623,7 +623,7 @@ internal class StytchApiTest { @Test fun `StytchApi TOTP recover calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true coEvery { StytchApi.apiService.totpsRecover(any()) } returns mockk(relaxed = true) StytchApi.TOTP.recover( @@ -635,7 +635,7 @@ internal class StytchApiTest { @Test fun `StytchApi Events logEvent calls appropriate apiService method`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true every { StytchApi.publicToken } returns "mock-public-token" coEvery { StytchApi.apiService.logEvent(any()) } returns mockk(relaxed = true) @@ -697,8 +697,8 @@ internal class StytchApiTest { } @Test(expected = StytchSDKNotConfiguredError::class) - fun `safeApiCall throws exception when StytchClient is not initialized`() = - runTest { + fun `safeApiCall throws exception when StytchClient is not initialized`(): Unit = + runBlocking { every { StytchApi.isInitialized } returns false val mockApiCall: suspend () -> StytchDataResponse = mockk() StytchApi.safeConsumerApiCall { mockApiCall() } @@ -706,7 +706,7 @@ internal class StytchApiTest { @Test fun `safeApiCall returns success when call succeeds`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse = StytchDataResponse(true) @@ -716,7 +716,7 @@ internal class StytchApiTest { @Test fun `safeApiCall returns correct error for HttpException`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse = @@ -731,7 +731,7 @@ internal class StytchApiTest { @Test fun `safeApiCall returns correct error for StytchErrors`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse = throw StytchAPIError(errorType = "", message = "") @@ -741,7 +741,7 @@ internal class StytchApiTest { @Test fun `safeApiCall returns correct error for other exceptions`() = - runTest { + runBlocking { every { StytchApi.isInitialized } returns true fun mockApiCall(): StytchDataResponse { diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/GoogleOneTapImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/GoogleOneTapImplTest.kt index 2583773a7..181335c01 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/GoogleOneTapImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/GoogleOneTapImplTest.kt @@ -25,8 +25,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -79,7 +79,7 @@ internal class GoogleOneTapImplTest { @Test fun `GoogleOneTapImpl start returns error if nonce fails to generate`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } throws RuntimeException("Testing") val result = impl.start(startParameters) assert(result is StytchResult.Error) @@ -96,7 +96,7 @@ internal class GoogleOneTapImplTest { @Test fun `GoogleOneTapImpl start returns error if returned an unexpected credential type`() = - runTest { + runBlocking { coEvery { mockGoogleCredentialManagerProvider.getSignInWithGoogleCredential( any(), @@ -119,7 +119,7 @@ internal class GoogleOneTapImplTest { @Test fun `GoogleOneTapImpl start returns error if create credential fails`() = - runTest { + runBlocking { coEvery { mockGoogleCredentialManagerProvider.getSignInWithGoogleCredential( any(), @@ -146,7 +146,7 @@ internal class GoogleOneTapImplTest { @Test fun `GoogleOneTapImpl delegates to api if everything is successful`() = - runTest { + runBlocking { coEvery { mockGoogleCredentialManagerProvider.getSignInWithGoogleCredential( any(), diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/OAuthImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/OAuthImplTest.kt index 9939df29e..0de50d991 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/OAuthImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/oauth/OAuthImplTest.kt @@ -27,8 +27,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -107,7 +107,7 @@ internal class OAuthImplTest { @Test fun `authenticate returns correct error if PKCE is missing`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val result = impl.authenticate(mockk(relaxed = true)) require(result is StytchResult.Error) @@ -116,7 +116,7 @@ internal class OAuthImplTest { @Test fun `authenticate returns correct error if api call fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("code-challenge", "code-verifier") coEvery { mockApi.authenticateWithThirdPartyToken(any(), any(), any()) } returns StytchResult.Error( @@ -131,7 +131,7 @@ internal class OAuthImplTest { @Test fun `authenticate returns success if api call succeeds`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("code-challenge", "code-verifier") coEvery { mockApi.authenticateWithThirdPartyToken(any(), any(), any()) } returns StytchResult.Success( diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt index eb4c0e5ba..27830a2ad 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt @@ -26,8 +26,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -74,7 +74,7 @@ internal class OTPImplTest { @Test fun `OTPImpl authenticate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.authenticateWithOTP(any(), any(), any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) assert(response is StytchResult.Success) @@ -92,7 +92,7 @@ internal class OTPImplTest { @Test fun `OTPImpl sms loginOrCreate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.loginOrCreateByOTPWithSMS(any(), any(), any(), any()) } returns mockk(relaxed = true) impl.sms.loginOrCreate(mockk(relaxed = true)) coVerify { mockApi.loginOrCreateByOTPWithSMS(any(), any(), any(), any()) } @@ -108,7 +108,7 @@ internal class OTPImplTest { @Test fun `OTPImpl sms send with active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendOTPWithSMSSecondary(any(), any()) } returns mockk(relaxed = true) impl.sms.send( @@ -122,7 +122,7 @@ internal class OTPImplTest { @Test fun `OTPImpl sms send with no active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithSMSPrimary(any(), any()) } returns mockk(relaxed = true) impl.sms.send( @@ -151,7 +151,7 @@ internal class OTPImplTest { @Test fun `OTPImpl whatsapp loginOrCreate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.loginOrCreateUserByOTPWithWhatsApp(any(), any()) } returns mockk(relaxed = true) impl.whatsapp.loginOrCreate(mockk(relaxed = true)) coVerify { mockApi.loginOrCreateUserByOTPWithWhatsApp(any(), any()) } @@ -167,7 +167,7 @@ internal class OTPImplTest { @Test fun `OTPImpl whatsapp send with no active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithWhatsAppPrimary(any(), any()) } returns mockk(relaxed = true) impl.whatsapp.send( @@ -181,7 +181,7 @@ internal class OTPImplTest { @Test fun `OTPImpl whatsapp send with active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendOTPWithWhatsAppSecondary(any(), any()) } returns mockk(relaxed = true) impl.whatsapp.send( @@ -210,7 +210,7 @@ internal class OTPImplTest { @Test fun `OTPImpl email loginOrCreate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.loginOrCreateUserByOTPWithEmail( any(), @@ -233,7 +233,7 @@ internal class OTPImplTest { @Test fun `OTPImpl email send with no active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithEmailPrimary(any(), any(), any(), any()) } returns mockk(relaxed = true) impl.email.send( @@ -249,7 +249,7 @@ internal class OTPImplTest { @Test fun `OTPImpl email send with active session delegates to api`() = - runTest { + runBlocking { every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendOTPWithEmailSecondary(any(), any(), any(), any()) } returns mockk(relaxed = true) impl.email.send( diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt index 8bcec07de..7dc431b36 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt @@ -25,8 +25,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -75,7 +75,7 @@ internal class PasskeysImplTest { @Test fun `register returns error if passkeys are unsupported`() = - runTest { + runBlocking { every { impl.isSupported } returns false val result = impl.register(mockk(relaxed = true)) require(result is StytchResult.Error) @@ -84,7 +84,7 @@ internal class PasskeysImplTest { @Test fun `register returns error if registerStart api call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true coEvery { mockApi.registerStart(any(), any(), any(), any()) } returns StytchResult.Error(mockk()) val result = impl.register(mockk(relaxed = true)) @@ -96,7 +96,7 @@ internal class PasskeysImplTest { @Test fun `register returns error if createPublicKeyCredential call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true coEvery { mockApi.registerStart(any(), any(), any(), any()) @@ -111,7 +111,7 @@ internal class PasskeysImplTest { @Test fun `register returns error if register api call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true coEvery { mockApi.registerStart(any(), any(), any(), any()) @@ -133,7 +133,7 @@ internal class PasskeysImplTest { @Test fun `register calls launchSessionUpdater and returns success if registration flow succeeds`() = - runTest { + runBlocking { every { impl.isSupported } returns true coEvery { mockApi.registerStart(any(), any(), any(), any()) @@ -165,7 +165,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if passkeys are unsupported`() = - runTest { + runBlocking { every { impl.isSupported } returns false val result = impl.authenticate(mockk()) require(result is StytchResult.Error) @@ -174,7 +174,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if authenticateStartSecondary api call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.authenticateStartSecondary(any(), any()) } returns StytchResult.Error(mockk()) @@ -188,7 +188,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if authenticateStartPrimary api call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.authenticateStartPrimary(any(), any()) } returns StytchResult.Error(mockk()) @@ -202,7 +202,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if getPublicKeyCredential call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { @@ -222,7 +222,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if authenticate api call fails`() = - runTest { + runBlocking { every { impl.isSupported } returns true every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { @@ -243,7 +243,7 @@ internal class PasskeysImplTest { @Test fun `authenticate calls launchSessionUpdater and returns success if authentication flow succeeds`() = - runTest { + runBlocking { every { impl.isSupported } returns true every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { @@ -274,7 +274,7 @@ internal class PasskeysImplTest { @Test fun `update delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.update(any(), any()) } returns mockk(relaxed = true) impl.update(Passkeys.UpdateParameters(id = "registration-id", name = "new name")) coVerify { mockApi.update("registration-id", "new name") } diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/passwords/PasswordsImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/passwords/PasswordsImplTest.kt index 5b4d2f67f..9179d39d0 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/passwords/PasswordsImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/passwords/PasswordsImplTest.kt @@ -32,8 +32,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -98,7 +98,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl authenticate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.authenticate(any(), any(), any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) assert(response is StytchResult.Success) @@ -116,7 +116,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl create delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.create(any(), any(), any()) } returns successfulCreateResponse val response = impl.create(createParameters) assert(response is StytchResult.Success) @@ -134,7 +134,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmailStart returns error if generateHashedCodeChallenge fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } throws RuntimeException("Test") val response = impl.resetByEmailStart(resetByEmailStartParameters) assert(response is StytchResult.Error) @@ -142,7 +142,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmailStart delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.generateAndReturnPKCECodePair() } returns PKCECodePair("", "") coEvery { mockApi.resetByEmailStart( @@ -172,7 +172,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmail returns error if codeVerifier fails`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns null val response = impl.resetByEmail(resetByEmailParameters) assert(response is StytchResult.Error) @@ -180,7 +180,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByEmail delegates to api`() = - runTest { + runBlocking { every { mockPKCEPairManager.getPKCECodePair() } returns PKCECodePair("", "") coEvery { mockApi.resetByEmail(any(), any(), any(), any()) } returns successfulAuthResponse impl.resetByEmail(resetByEmailParameters) @@ -199,7 +199,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetBySession delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.resetBySession(any(), any()) } returns mockk() impl.resetBySession(resetBySessionParameters) coVerify { mockApi.resetBySession(any(), any()) } @@ -215,7 +215,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl resetByExistingPassword delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.resetByExisting(any(), any(), any(), any()) } returns successfulAuthResponse impl.resetByExistingPassword(mockk(relaxed = true)) coVerify { mockApi.resetByExisting(any(), any(), any(), any()) } @@ -232,7 +232,7 @@ internal class PasswordsImplTest { @Test fun `PasswordsImpl strengthCheck delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.strengthCheck(any(), any()) } returns mockk() impl.strengthCheck(strengthCheckParameters) coVerify { mockApi.strengthCheck(any(), any()) } diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorageTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorageTest.kt index acb09fc03..6b8333443 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorageTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorageTest.kt @@ -1,18 +1,23 @@ package com.stytch.sdk.consumer.sessions +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_JWT import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_TOKEN import com.stytch.sdk.common.StorageHelper import com.stytch.sdk.common.errors.StytchNoCurrentSessionError import com.stytch.sdk.consumer.extensions.keepLocalBiometricRegistrationsInSync import com.stytch.sdk.consumer.network.models.UserData +import io.mockk.MockKAnnotations import io.mockk.every +import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import org.junit.Before import org.junit.Test @@ -21,12 +26,25 @@ import java.security.KeyStore internal class ConsumerSessionStorageTest { private lateinit var impl: ConsumerSessionStorage + @MockK + private lateinit var mockSharedPreferences: SharedPreferences + + @MockK + private lateinit var mockSharedPreferencesEditor: Editor + @Before fun before() { + MockKAnnotations.init(this, true, true) mockkStatic(KeyStore::class) mockkStatic("com.stytch.sdk.consumer.extensions.UserDataExtKt") every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) mockkObject(StorageHelper) + every { mockSharedPreferences.edit() } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putString(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferencesEditor.putLong(any(), any()) } returns mockSharedPreferencesEditor + every { mockSharedPreferences.getLong(any(), any()) } returns 0L + every { mockSharedPreferencesEditor.apply() } just runs + StorageHelper.sharedPreferences = mockSharedPreferences every { StorageHelper.loadValue(any()) } returns null every { StorageHelper.saveValue(any(), any()) } just runs impl = ConsumerSessionStorage(StorageHelper, TestScope()) @@ -51,12 +69,13 @@ internal class ConsumerSessionStorageTest { } @Test - fun `setting a user keeps local biometric registrations in check`() { - val mockUserData: UserData = - mockk(relaxed = true) { - every { keepLocalBiometricRegistrationsInSync(any()) } just runs - } - impl.user = mockUserData - verify { mockUserData.keepLocalBiometricRegistrationsInSync(any()) } - } + fun `setting a user keeps local biometric registrations in check`() = + runBlocking { + val mockUserData: UserData = + mockk(relaxed = true) { + every { keepLocalBiometricRegistrationsInSync(any()) } just runs + } + impl.user = mockUserData + verify { mockUserData.keepLocalBiometricRegistrationsInSync(any()) } + } } diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionStorageTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionStorageTest.kt index 528ef898f..5203b9cdf 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionStorageTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionStorageTest.kt @@ -1,5 +1,7 @@ package com.stytch.sdk.consumer.sessions +import com.stytch.sdk.common.PREFERENCES_NAME_LAST_VALIDATED_AT +import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_DATA import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_JWT import com.stytch.sdk.common.PREFERENCES_NAME_SESSION_TOKEN import com.stytch.sdk.common.StorageHelper @@ -31,6 +33,10 @@ internal class SessionStorageTest { mockkStatic(KeyStore::class) every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) MockKAnnotations.init(this, true, true) + every { mockStorageHelper.loadValue(any()) } returns "{}" + every { mockStorageHelper.saveValue(any(), any()) } just runs + every { mockStorageHelper.saveLong(any(), any()) } just runs + every { mockStorageHelper.getLong(any()) } returns 0 storage = ConsumerSessionStorage(mockStorageHelper, TestScope()) } @@ -79,7 +85,8 @@ internal class SessionStorageTest { sessionJwt = "mySessionJwt", session = mockedSessionData, ) - assert(storage.session == mockedSessionData) + verify { mockStorageHelper.saveValue(PREFERENCES_NAME_SESSION_DATA, any()) } + verify { mockStorageHelper.saveLong(PREFERENCES_NAME_LAST_VALIDATED_AT, any()) } } @Test diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionsImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionsImplTest.kt index 32e39e18e..ee7f8d17c 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionsImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/sessions/SessionsImplTest.kt @@ -26,12 +26,13 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date internal class SessionsImplTest { @MockK @@ -58,6 +59,9 @@ internal class SessionsImplTest { every { SessionAutoUpdater.startSessionUpdateJob(any(), any(), any()) } just runs every { mockSessionStorage.userFlow } returns mockk(relaxed = true) every { mockSessionStorage.sessionFlow } returns mockk(relaxed = true) + every { mockSessionStorage.lastValidatedAtFlow } returns mockk(relaxed = true) + every { mockSessionStorage.session } returns mockk(relaxed = true) + every { mockSessionStorage.lastValidatedAt } returns Date(0L) impl = SessionsImpl( externalScope = TestScope(), @@ -97,7 +101,7 @@ internal class SessionsImplTest { @Test fun `SessionsImpl authenticate delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.authenticate(any()) } returns successfulAuthResponse val response = impl.authenticate(authParameters) assert(response is StytchResult.Success) @@ -115,7 +119,7 @@ internal class SessionsImplTest { @Test fun `SessionsImpl revoke delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns mockk() impl.revoke() coVerify { mockApi.revoke() } @@ -123,7 +127,7 @@ internal class SessionsImplTest { @Test fun `SessionsImpl revoke does not revoke a local session if a network error occurs and forceClear is not true`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns StytchResult.Error(mockk(relaxed = true)) impl.revoke() verify(exactly = 0) { mockSessionStorage.revoke() } @@ -131,7 +135,7 @@ internal class SessionsImplTest { @Test fun `SessionsImpl revoke does revoke a local session if a network error occurs and forceClear is true`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns StytchResult.Error(mockk(relaxed = true)) impl.revoke(Sessions.RevokeParams(true)) verify { mockSessionStorage.revoke() } @@ -139,7 +143,7 @@ internal class SessionsImplTest { @Test fun `SessionsImpl revoke returns error if sessionStorage revoke fails`() = - runTest { + runBlocking { coEvery { mockApi.revoke() } returns StytchResult.Success(mockk(relaxed = true)) every { mockSessionStorage.revoke() } throws RuntimeException("Test") val result = impl.revoke(Sessions.RevokeParams(true)) diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/totp/TOTPImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/totp/TOTPImplTest.kt index 22d4c55a4..91b4ca76f 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/totp/TOTPImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/totp/TOTPImplTest.kt @@ -27,8 +27,8 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -68,7 +68,7 @@ internal class TOTPImplTest { @Test fun `TOTP Create delegates to appropriate api method`() = - runTest { + runBlocking { coEvery { mockApi.create(any()) } returns mockk(relaxed = true) impl.create(mockk(relaxed = true)) coVerify(exactly = 1) { mockApi.create(any()) } @@ -85,7 +85,7 @@ internal class TOTPImplTest { @Test fun `TOTP Authenticate delegates to appropriate api method`() = - runTest { + runBlocking { coEvery { mockApi.authenticate(any(), any()) } returns successfulAuthResponse impl.authenticate(mockk(relaxed = true)) coVerify(exactly = 1) { mockApi.authenticate(any(), any()) } @@ -104,7 +104,7 @@ internal class TOTPImplTest { @Test fun `TOTP RecoveryCodes delegates to appropriate api method`() = - runTest { + runBlocking { coEvery { mockApi.recoveryCodes() } returns mockk(relaxed = true) impl.recoveryCodes() coVerify(exactly = 1) { mockApi.recoveryCodes() } @@ -121,7 +121,7 @@ internal class TOTPImplTest { @Test fun `TOTP Recover delegates to appropriate api method`() = - runTest { + runBlocking { coEvery { mockApi.recover(any(), any()) } returns successfulRecoverResponse impl.recover(mockk(relaxed = true)) coVerify(exactly = 1) { mockApi.recover(any(), any()) } diff --git a/source/sdk/src/test/java/com/stytch/sdk/consumer/userManagement/UserManagementImplTest.kt b/source/sdk/src/test/java/com/stytch/sdk/consumer/userManagement/UserManagementImplTest.kt index 817dc9359..804ae9aff 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/consumer/userManagement/UserManagementImplTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/consumer/userManagement/UserManagementImplTest.kt @@ -27,12 +27,13 @@ import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import java.security.KeyStore +import java.util.Date internal class UserManagementImplTest { @MockK @@ -58,6 +59,9 @@ internal class UserManagementImplTest { every { mockSessionStorage.user = any() } just runs every { mockSessionStorage.userFlow } returns mockk(relaxed = true) every { mockSessionStorage.sessionFlow } returns mockk(relaxed = true) + every { mockSessionStorage.lastValidatedAtFlow } returns mockk(relaxed = true) + every { mockSessionStorage.user } returns mockk(relaxed = true) + every { mockSessionStorage.lastValidatedAt } returns Date(0L) impl = UserManagementImpl( externalScope = TestScope(), @@ -75,7 +79,7 @@ internal class UserManagementImplTest { @Test fun `UserManagementImpl getUser delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.getUser() } returns StytchResult.Success(mockk(relaxed = true)) val response = impl.getUser() assert(response is StytchResult.Success) @@ -110,7 +114,7 @@ internal class UserManagementImplTest { @Test fun `UserManagementImpl deleteFactor delegates to api for all supported factors`() = - runTest { + runBlocking { coEvery { mockApi.deleteEmailById(any()) } returns StytchResult.Success(mockk(relaxed = true)) coEvery { mockApi.deletePhoneNumberById(any()) } returns StytchResult.Success(mockk(relaxed = true)) coEvery { @@ -142,7 +146,7 @@ internal class UserManagementImplTest { @Test fun `UserManagementImpl updateUser delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.updateUser(any(), any()) } returns StytchResult.Success(mockk(relaxed = true)) val response = impl.update(mockk(relaxed = true)) assert(response is StytchResult.Success) @@ -159,7 +163,7 @@ internal class UserManagementImplTest { @Test fun `UserManagementImpl searchUsers delegates to api`() = - runTest { + runBlocking { coEvery { mockApi.searchUsers(any()) } returns StytchResult.Success(mockk(relaxed = true)) val response = impl.search(mockk(relaxed = true)) assert(response is StytchResult.Success) diff --git a/source/sdk/src/test/java/com/stytch/sdk/ui/AuthenticationViewModelTest.kt b/source/sdk/src/test/java/com/stytch/sdk/ui/AuthenticationViewModelTest.kt index f7db11f88..a1902bfe4 100644 --- a/source/sdk/src/test/java/com/stytch/sdk/ui/AuthenticationViewModelTest.kt +++ b/source/sdk/src/test/java/com/stytch/sdk/ui/AuthenticationViewModelTest.kt @@ -21,6 +21,7 @@ import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After @@ -52,7 +53,7 @@ internal class AuthenticationViewModelTest { @Test fun `authenticateThirdPartyOAuth emits the expected event based on intent when success`() = - runTest(dispatcher) { + runBlocking(dispatcher) { val resultData = StytchResult.Success(mockk()) coEvery { mockStytchClient.handle(any(), any()) } returns mockk(relaxed = true) { @@ -78,7 +79,7 @@ internal class AuthenticationViewModelTest { @Test fun `authenticateThirdPartyOAuth emits the expected event based on exception when failed`() = - runTest(dispatcher) { + runBlocking(dispatcher) { val eventFlow = async { viewModel.eventFlow.first() diff --git a/tutorials/Sessions.md b/tutorials/Sessions.md index 624e5a959..c0c42f2ce 100644 --- a/tutorials/Sessions.md +++ b/tutorials/Sessions.md @@ -1,84 +1,102 @@ # Sessions -Stytch user sessions are identified by a session token and a JWT (JSON Web Token) that are authenticated on, and returned from, our authentication endpoints. The Stytch Android SDK automatically persists these tokens and appends them to network requests as required, to make interacting with Stytch's authentication flows as simple as possible, and provides both Automatic and Manual session management helpers. +Stytch user sessions are identified by a `SessionData` or `B2BSessionData` object, a session token, and a JWT (JSON Web Token) that are authenticated on, and returned from, our authentication endpoints. The Stytch Android SDK automatically persists these tokens and appends them to network requests as required, to make interacting with Stytch's authentication flows as simple as possible, and provides both Automatic and Manual session management helpers. -## SDK Sessions At-A-Glance -### Session Data Persistence -The Stytch Android SDK persists the session token and JWT to device by saving them to `SharedPreferences`, after encrypting them using AES256-GCM. The actual session and user data is stored in-memory only. +## Session Data Persistence +The Stytch Android SDK persists to `SharedPreferences` a few sets of encrypted values (using AES256-GCM) which are accessible across launches and help with session management: +1. The session token and JWT. +2. For the consumer client: `SessionData` and `UserData`. +3. For the B2B client: `B2BSessionData`, `MemberData` and `OrganizationData`. -### Automatic Session Management -When the Stytch Android SDK is initialized, it decrypts the persisted session token, if any, and makes a Sessions Authenticate call to ensure the session is still active/valid. If it is, it will automatically rehydrate the session and user data in memory; if not, it clears all persisted session tokens. +The `SessionData` / `B2BSessionData` object is the definitive source of truth for if the user is logged in or not as it contains an expiration date property for when the session expires. -On every authentication response, the SDK updates the in-memory and device-persisted session data with the latest data returned from the endpoint. In addition, once the SDK receives a valid session, it begins an automatic "heartbeat" job that checks for continued session validity in the background, roughly every three minutes. This heartbeat does not extend an existing session, it merely checks it's validity and updates the local data as appropriate. +## Automatic Session Management +When the Stytch Android SDK is initialized, it decrypts the persisted session tokens and `SessionData` / `B2BSessionData` object, if any, and attempts to make a Sessions Authenticate call to ensure the session is still active/valid. On every authentication response, the SDK updates the in-memory and device-persisted session data with the latest data returned from the endpoint. In addition, once the SDK receives a valid session, it begins an automatic "heartbeat" job that checks for continued session validity in the background, roughly every three minutes. This heartbeat does not extend an existing session, it merely checks it's validity and updates the local data as appropriate. You as the developer can either observe changes in the session via the `onChange` flow/callback shown below, or directly though the `StytchClient.sessions.session` which both access the Session object stored in `SharedPreferences`. -#### Cached Data -Because the Stytch Android SDK performs automatic session and user syncing operations, it is not advised, or necessary, to cache the results of authentication requests. Doing so risks caching and using stale data, which may be a source of bugs in your application. When you need access to session or user data, you should always retrieve them from the appropriate Stytch client, as outlined in the next section. +It is then good practice when using the Stytch Android SDK to access any data that may have been updated via the heartbeat call through their cached values. Otherwise if you hold onto a reference of a response that contains authentication information in it, like token or user, you risk the those values returned in the response becoming stale. Values updated via the heartbeat are: `SessionData`, `UserData`, `B2BSessionData`, `MemberData` `OrganizationData`, `sessionToken` and `sessionJwt`. -### Manual Session/User Management and Observation -The [Sessions client](../source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/Sessions.kt) provides properties to retrieve the current session tokens; methods for authenticating, updating, and revoking sessions; and a flow/callback to listen for changes in session state. The [UserManagement client](../source/sdk/src/main/java/com/stytch/sdk/consumer/userManagement/UserManagement.kt) provides methods for retrieving the current user (if any); methods for updating the currently authenticated user; and a flow/callback to listen for changes in the user object. - -To retrieve any existing session or user data, access the appropriate property or method, which will return the decrypted value to you, if it exists. This may be useful if you need to parse a JWT or use the token for a call from your backend, or need access to the in-memory session or user data: +Examples for retrieving these cached values: ```kotlin +// tokens val sessionToken: String? = StytchClient.sessions.sessionToken val sessionJwt: String? = StytchClient.sessions.sessionJwt + +// consumer objects val sessionData: SessionData? = StytchClient.sessions.getSync() val userData: UserData? = StytchClient.user.getSyncUser() ``` -Authenticating and Revoking sessions are similarly easy: -```kotlin -val authResponse = StytchClient.sessions.authenticate(Sessions.AuthParams()) -val revokeResponse = StytchClient.sessions.revoke(Sessions.RevokeParams()) -``` -Updating a session with tokens retrieved outside of the SDK (for instance, if you create or update a session on your backend, and want to hydrate a client application) can be done using the `updateSession` method: + +### Observing Stytch Object Information + +To unify the publishing of various Stytch object types such as `SessionData`, `UserData`, `B2BSessionData`, `MemberData`, and `OrganizationData`, the SDK provides the `StytchObjectInfo` type. This generic type handles the publishing of objects in a way that avoids having to publish null values. +* If an object is available for publishing, `StytchObjectInfo.Available` is emitted, containing a `value` property of type `T` representing the desired data and a `lastValidatedAt` property, representing the last time that the data was validated with Stytch's servers. The receiver can then determine if the object is within their acceptable time tolerance for use. +* If there is no object to publish, `StytchObjectInfo.Unavailable` will be emitted instead. This ensures that the flow/callback never needs to send a null value. ```kotlin -StytchClient.sessions.updateSession( - sessionToken="my-session-token", - sessionJwt="my-session-jwt" -) +public sealed interface StytchObjectInfo { + public data object Unavailable : StytchObjectInfo + + public data class Available( + val lastValidatedAt: Date, + val value: T, + ) : StytchObjectInfo +} ``` -Lastly, to listen for session or user state changes, you can either subscribe to the appropriate `onChange` flow, or provide a callback to the `onChange` method: +Each of the five object types—`SessionData`, `UserData`, `B2BSessionData`, `MemberData`, and `OrganizationData` has its own dedicated `onChange` flow/callback, ensuring that state changes for each type are handled individually. In the example below, we show the session flow/callback, but similar flows/callbacks exist for each of the other object types. This mechanism simplifies state management and ensures clean, consistent data flow throughout your app when interacting with these Stytch objects. + +For session state changes specifically, you can subscribe to the `onChange` flow/callback: ```kotlin +// Flow: viewModelScope.launch { StytchClient.sessions.onChange.collect { - // it: SessionData? + // it: StytchObjectInfo when(it) { - null -> println("No active session") - else -> println("User has an active session") + is StytchObject.Available -> println("User has an active session") + is StytchObject.Unavailable -> println("No active session") } } } -viewModelScope.launch { - StytchClient.user.onChange.collect { - // it: UserData? - when(it) { - null -> println("There is no user") - else -> println("User exists") - } - } -} -``` -```kotlin + +// Callback: StytchClient.sessions.onChange { - // it: SessionData? + // it: StytchObjectInfo when(it) { - null -> println("No active session") - else -> println("User has an active session") - } -} -StytchClient.user.onChange { - // it: UserData? - when(it) { - null -> println("There is no user") - else -> println("User exists") + is StytchObject.Available -> println("User has an active session") + is StytchObject.Unavailable -> println("No active session") } } ``` - ## Creating or Extending a Session On all authentication requests, you can pass an optional parameter indicating the length of time a session should be valid for. This will be validated on the Stytch servers to ensure that it is within the minimum and maximum values configured in your Stytch dashboard (between 5 minutes and 1 year). Every authentication call that supplies a session duration (and succeeds!) will either create a session (if none exists), or extend the session duration by that length of time (if there is an active session). -With the exception of Sessions Authenticate calls, if you do not provide a session duration, the SDK will default it to 5 minutes. The Sessions Authenticate call is special, in that there is no default session duration if none is passed. This enables the "heartbeat" functionality discussed earlier. If you call `StytchClient.sessions.authenticate(Sessions.AuthParams())`, it will merely respond with whether or not the session is active; if you pass in `Sessions.AuthParams(sessionDurationMinutes = 5))`, it will behave like all other endpoints and extend the existing session by 5 minutes. +With the exception of Sessions Authenticate calls, if you do not provide a session duration, the SDK will default it to 5 minutes. The Sessions Authenticate call is special, in that there is no default session duration if none is passed. This enables the "heartbeat" functionality discussed earlier. + +If you call authenticate with no `sessionDurationMinutes` it will merely respond with whether or not the session is active. +```kotlin +StytchClient.sessions.authenticate(Sessions.AuthParams()) +``` +If you do pass in a `sessionDurationMinutes` it will behave like all other endpoints and extend the existing session by 5 minutes. +```kotlin +StytchClient.sessions.authenticate(Sessions.AuthParams(sessionDurationMinutes = 5)) +``` +## Manual Session Management +The [Sessions client](../source/sdk/src/main/java/com/stytch/sdk/consumer/sessions/Sessions.kt) provides an interface for managing the session. + +Authenticating and Revoking Sessions: +```kotlin +// Authenticate +val authenticateResponse = StytchClient.sessions.authenticate(Sessions.AuthParams()) + +// Revoke - clears all values for `Session`, `User`, `MemberSession`, `Member` `Organization`, `sessionToken` and `sessionJwt` +val revokeResponse = StytchClient.sessions.revoke(Sessions.RevokeParams()) +``` +Updating a session with tokens retrieved outside of the SDK (for instance, if you create or update a session on your backend, and want to hydrate a client application) can be done using the `updateSession` method: +```kotlin +// update the local tokens +StytchClient.sessions.updateSession(sessionToken: "sessionToken", sessionJwt: "sessionJwt") +// Authenticate with the new tokens +val authenticateResponse = StytchClient.sessions.authenticate(Sessions.AuthParams()) +``` ## Further Reading For more information on the Stytch Sessions product, consult our [sessions guide](https://stytch.com/docs/guides/sessions/using-sessions). \ No newline at end of file diff --git a/workbench-apps/uiworkbench/src/main/java/com/stytch/uiworkbench/UiWorkbenchViewModel.kt b/workbench-apps/uiworkbench/src/main/java/com/stytch/uiworkbench/UiWorkbenchViewModel.kt index 35dd451fb..a34401e4a 100644 --- a/workbench-apps/uiworkbench/src/main/java/com/stytch/uiworkbench/UiWorkbenchViewModel.kt +++ b/workbench-apps/uiworkbench/src/main/java/com/stytch/uiworkbench/UiWorkbenchViewModel.kt @@ -2,6 +2,7 @@ package com.stytch.uiworkbench import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.stytch.sdk.common.StytchObjectInfo import com.stytch.sdk.consumer.StytchClient import com.stytch.sdk.consumer.network.models.UserData import kotlinx.coroutines.flow.MutableStateFlow @@ -16,7 +17,13 @@ class UiWorkbenchViewModel : ViewModel() { init { _userState.value = StytchClient.user.getSyncUser() StytchClient.user.onChange { - _userState.value = it + val userData = + if (it is StytchObjectInfo.Available) { + it.value + } else { + null + } + _userState.value = userData } }