diff --git a/Gemfile.lock b/Gemfile.lock index 452160389..22be57aee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1257.0) - aws-sdk-core (3.251.0) + aws-partitions (1.1283.0) + aws-sdk-core (3.254.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,11 +17,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.129.0) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (1.130.0) + aws-sdk-core (~> 3, >= 3.254.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.225.0) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-s3 (1.229.0) + aws-sdk-core (~> 3, >= 3.254.1) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -35,47 +35,36 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - csv (3.3.5) + csv (3.3.6) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20240107) + domain_name (0.6.20260902) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.5) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + erb (6.0.7) + excon (1.7.1) + logger + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.235.0) + fastlane (2.238.0) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) - addressable (>= 2.8, < 3.0.0) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) @@ -87,10 +76,12 @@ GEM csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) @@ -100,10 +91,12 @@ GEM google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) - jwt (>= 2.1.0, < 4) + jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) mutex_m (~> 0.3) naturally (~> 2.2) @@ -115,7 +108,7 @@ GEM security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -130,34 +123,35 @@ GEM google-apis-firebaseappdistribution_v1alpha (>= 0.12.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.101.0) + google-apis-androidpublisher_v3 (0.107.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-core (0.18.0) - addressable (~> 2.5, >= 2.5.1) - googleauth (~> 1.9) - httpclient (>= 2.8.3, < 3.a) - mini_mime (~> 1.0) - mutex_m + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) representable (~> 3.0) - retriable (>= 2.0, < 4.a) - google-apis-firebaseappdistribution_v1 (0.19.0) + retriable (>= 3.1, < 5.0) + google-apis-firebaseappdistribution_v1 (0.22.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-firebaseappdistribution_v1alpha (0.28.0) + google-apis-firebaseappdistribution_v1alpha (0.30.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-iamcredentials_v1 (0.27.0) + google-apis-iamcredentials_v1 (0.28.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-playcustomapp_v1 (0.17.0) + google-apis-playcustomapp_v1 (0.18.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.63.0) + google-apis-storage_v1 (0.66.0) google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.8.0) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.2.2) base64 (~> 0.2) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.60.0) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -167,7 +161,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.17.0) + googleauth (1.17.4) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -179,17 +173,21 @@ GEM http-accept (1.7.0) http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.9.0) - mutex_m + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) - json (2.19.8) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0414) + mime-types-data (3.2026.0701) mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.21.1) @@ -197,15 +195,32 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) netrc (0.11.0) - nkf (0.2.0) + nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -215,10 +230,9 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - retriable (3.8.0) + retriable (4.2.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) signet (0.22.0) @@ -229,22 +243,28 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) word_wrap (1.0.0) - xcodeproj (1.27.0) + xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) + base64 claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.4.0) + nkf rexml (>= 3.3.6, < 4.0) xcpretty (0.4.1) rouge (~> 3.28.0) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa90b7fe5..9dd6da7f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ kotlin = "2.1.21" ksp = "2.1.21-2.0.1" # judokit-android -judo3ds2 = "2.1.0" +judo3ds2 = "2.2.0" kotlinxCoroutines = "1.9.0" retrofit2 = "2.11.0" coreKtx = "1.16.0" diff --git a/judokit-android-examples/src/main/java/com/judokit/android/examples/feature/DemoFeatureListActivity.kt b/judokit-android-examples/src/main/java/com/judokit/android/examples/feature/DemoFeatureListActivity.kt index 46d60b21d..807a9904a 100644 --- a/judokit-android-examples/src/main/java/com/judokit/android/examples/feature/DemoFeatureListActivity.kt +++ b/judokit-android-examples/src/main/java/com/judokit/android/examples/feature/DemoFeatureListActivity.kt @@ -34,8 +34,7 @@ import com.judokit.android.examples.settings.readImportedJson import com.judokit.android.examples.settings.showImportSettingsDialog import com.judopay.judokit.android.JUDO_OPTIONS import com.judopay.judokit.android.JudoActivityResultContracts -import com.judopay.judokit.android.api.factory.JudoApiServiceFactory -import com.judopay.judokit.android.api.factory.RecommendationApiServiceFactory +import com.judopay.judokit.android.api.factory.JudoHttpInterceptors import com.judopay.judokit.android.model.JudoPaymentResult import com.judopay.judokit.android.model.PaymentWidgetType import kotlinx.coroutines.launch @@ -138,8 +137,7 @@ class DemoFeatureListActivity : AppCompatActivity() { .alwaysReadResponseBody(false) .build(), ) - JudoApiServiceFactory.externalInterceptors = interceptors - RecommendationApiServiceFactory.externalInterceptors = interceptors + JudoHttpInterceptors.interceptors = interceptors } private fun setupRecyclerView() { diff --git a/judokit-android-examples/src/main/java/com/judokit/android/examples/settings/fragments/RootFragment.kt b/judokit-android-examples/src/main/java/com/judokit/android/examples/settings/fragments/RootFragment.kt index a526054c6..bb5f114c6 100644 --- a/judokit-android-examples/src/main/java/com/judokit/android/examples/settings/fragments/RootFragment.kt +++ b/judokit-android-examples/src/main/java/com/judokit/android/examples/settings/fragments/RootFragment.kt @@ -19,6 +19,7 @@ import com.judokit.android.examples.apiclient.ApiClient import com.judokit.android.examples.apiclient.CreatePaymentSessionRequest import com.judokit.android.examples.apiclient.CreatePaymentSessionResponse import com.judopay.judokit.android.model.ApiEnvironment +import com.judopay.judokit.android.service.DsCertificateCache import com.judopay.judokit.android.ui.common.ButtonState import com.judopay.judokit.android.ui.common.ProgressButton import okhttp3.OkHttpClient @@ -67,6 +68,12 @@ class RootFragment : PreferenceFragmentCompat() { View.OnClickListener { createPaymentSession(it as ProgressButton) } + + findPreference("clear_ds_certificate_cache")?.setOnPreferenceClickListener { + DsCertificateCache.clear(requireContext()) + Toast.makeText(activity, R.string.clear_ds_certificate_cache_done, Toast.LENGTH_SHORT).show() + true + } } private fun createPaymentSession(progressButton: ProgressButton) { diff --git a/judokit-android-examples/src/main/res/values/strings.xml b/judokit-android-examples/src/main/res/values/strings.xml index 1bff9ca39..93fd51b24 100644 --- a/judokit-android-examples/src/main/res/values/strings.xml +++ b/judokit-android-examples/src/main/res/values/strings.xml @@ -309,6 +309,10 @@ Override 3DS 2.0 protocol message version Message version + Clear DS Certificate Cache + Removes cached 3DS 2.0 directory-server certificates so they are re-fetched from the CDN + DS certificate cache cleared + Card details Card number Expiration date (MM/YY) diff --git a/judokit-android-examples/src/main/res/xml/root_preferences.xml b/judokit-android-examples/src/main/res/xml/root_preferences.xml index e440c4e15..8eac0c1ad 100644 --- a/judokit-android-examples/src/main/res/xml/root_preferences.xml +++ b/judokit-android-examples/src/main/res/xml/root_preferences.xml @@ -184,6 +184,12 @@ app:iconSpaceReserved="false" app:summary="Customise UI elements of the challenge screen" app:title="UI customization" /> + + (Lcom/google/android/gms/wallet/PaymentsClient;Lcom/judopay/judokit/android/Judo;Landroidx/activity/result/ActivityResultLauncher;)V public final fun checkIfGooglePayIsAvailable (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/DsCdnApiService.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/DsCdnApiService.kt new file mode 100644 index 000000000..c784de36f --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/DsCdnApiService.kt @@ -0,0 +1,18 @@ +package com.judopay.judokit.android.api + +import com.judopay.judokit.android.api.model.response.cdn.DsCertsResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.Url + +internal interface DsCdnApiService { + @GET + @Headers("Accept: application/json") + suspend fun fetchDsCerts( + @Url url: String, + @Header("If-None-Match") ifNoneMatch: String?, + @Header("If-Modified-Since") ifModifiedSince: String?, + ): Response +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/DsCdnApiServiceFactory.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/DsCdnApiServiceFactory.kt new file mode 100644 index 000000000..684b64c45 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/DsCdnApiServiceFactory.kt @@ -0,0 +1,46 @@ +package com.judopay.judokit.android.api.factory + +import android.content.Context +import com.google.gson.GsonBuilder +import com.judopay.judokit.android.Judo +import com.judopay.judokit.android.api.AppMetaDataProvider +import com.judopay.judokit.android.api.DsCdnApiService +import com.judopay.judokit.android.api.interceptor.NetworkConnectivityInterceptor +import com.judopay.judokit.android.api.interceptor.UserAgentInterceptor +import com.judopay.judokit.android.apiBaseUrl +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +private const val TIMEOUT_SECONDS = 10L + +@Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") +internal object DsCdnApiServiceFactory { + fun create( + context: Context, + judo: Judo, + ): DsCdnApiService = + try { + val builder = + JudoTlsConfigurator + .applyTls12WithPinning(OkHttpClient.Builder()) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .addInterceptor(NetworkConnectivityInterceptor(context)) + .addInterceptor(UserAgentInterceptor(AppMetaDataProvider(context, judo.subProductInfo))) + + // Host-supplied debug interceptors shared across all judo SDK HTTP clients. + builder.interceptors().addAll(JudoHttpInterceptors.interceptors) + + Retrofit + .Builder() + .baseUrl(judo.apiBaseUrl) + .client(builder.build()) + .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) + .build() + .create(DsCdnApiService::class.java) + } catch (e: Exception) { + throw RuntimeException(e) + } +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoApiServiceFactory.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoApiServiceFactory.kt index 4db9cd62f..5ffb020a1 100644 --- a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoApiServiceFactory.kt +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoApiServiceFactory.kt @@ -17,29 +17,19 @@ import com.judopay.judokit.android.apiBaseUrl import com.judopay.judokit.android.model.ChallengeRequestIndicator import com.judopay.judokit.android.model.NetworkTimeout import com.judopay.judokit.android.model.ScaExemption -import okhttp3.CertificatePinner -import okhttp3.ConnectionSpec -import okhttp3.Interceptor import okhttp3.OkHttpClient -import okhttp3.TlsVersion import java.math.BigDecimal -import java.security.KeyStore import java.util.Date import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager /** * Factory that provides the [JudoApiService] used for performing all HTTP requests to the - * judoPay APIs. As implementation of the ApiService requires some configuration, it is better + * JudoPay APIs. As implementation of the ApiService requires some configuration, it is better * to use a shared instance than create a new instance per request, so this class ensures that only * one instance is used in the application. */ @Suppress("TooGenericExceptionCaught", "TooGenericExceptionCaught", "TooGenericExceptionThrown") object JudoApiServiceFactory : ServiceFactory() { - private const val HOSTNAME_WILDCARD_PATTERN = "*.judopay.com" - override val gson: Gson get() = GsonBuilder() @@ -51,13 +41,11 @@ object JudoApiServiceFactory : ServiceFactory() { ChallengeRequestIndicatorSerializer(), ).create() - override var externalInterceptors: List? = null - /** * @param context the calling Context * @param judo the judo instance * @return the Retrofit API service implementation containing the methods used - * for interacting with the judoPay REST API. + * for interacting with the JudoPay REST API. */ @Deprecated("Use create instead", replaceWith = ReplaceWith("create(context, judo)")) @@ -78,43 +66,7 @@ object JudoApiServiceFactory : ServiceFactory() { judo: Judo, ): OkHttpClient = try { - val sslContext = SSLContext.getInstance("TLSv1.2") - sslContext.init(null, null, null) - - val trustManagerFactory = - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(null as KeyStore?) - - val trustManagers = trustManagerFactory.trustManagers - check(!(trustManagers.size != 1 || trustManagers.first() !is X509TrustManager)) { - "Unexpected default trust managers: ${trustManagers.contentToString()}" - } - - val trustManager = trustManagers.first() as X509TrustManager - val specs = mutableListOf() - - specs.add( - ConnectionSpec - .Builder(ConnectionSpec.MODERN_TLS) - .tlsVersions(TlsVersion.TLS_1_2) - .build(), - ) - - val builder = - OkHttpClient - .Builder() - .sslSocketFactory(Tls12SslSocketFactory(sslContext.socketFactory), trustManager) - .connectionSpecs(specs) - - builder.certificatePinner( - CertificatePinner - .Builder() - .add( - HOSTNAME_WILDCARD_PATTERN, - "sha256/SuY75QgkSNBlMtHNPeW9AayE7KNDAypMBHlJH9GEhXs=", - "sha256/c4zbAoMygSbepJKqU3322FvFv5unm+TWZROW3FHU1o8=", - ).build(), - ) + val builder = JudoTlsConfigurator.applyTls12WithPinning(OkHttpClient.Builder()) setTimeouts(builder, judo.networkTimeout) addInterceptors(builder, context, judo) diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoHttpInterceptors.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoHttpInterceptors.kt new file mode 100644 index 000000000..6c04fb12f --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoHttpInterceptors.kt @@ -0,0 +1,16 @@ +package com.judopay.judokit.android.api.factory + +import okhttp3.Interceptor + +/** + * Single source of truth for host-supplied OkHttp interceptors (e.g. Chucker for debug + * inspection) applied to *all* judo SDK HTTP clients — the judo API, the Recommendation API + * and the DS-certificate CDN. + * + * Set this once at app start, before the first network call. Intended for debug builds only; + * do not ship logging/inspection interceptors to production. + */ +object JudoHttpInterceptors { + @Volatile + var interceptors: List = emptyList() +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoTlsConfigurator.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoTlsConfigurator.kt new file mode 100644 index 000000000..fde4e8148 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/JudoTlsConfigurator.kt @@ -0,0 +1,63 @@ +package com.judopay.judokit.android.api.factory + +import okhttp3.CertificatePinner +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.TlsVersion +import java.security.KeyStore +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * Single source of truth for the TLS 1.2 socket configuration and the JudoPay certificate + * pinning applied to judo SDK HTTP clients that talk to `*.judopay.com` (the judo API and the + * DS-certificate CDN). + * + * Keeping the pins in one place ensures every judopay.com client trusts exactly the same leaf + * keys, so a pin rotation is a single edit. + */ +internal object JudoTlsConfigurator { + private const val HOSTNAME_WILDCARD_PATTERN = "*.judopay.com" + private const val PIN_PRIMARY = "sha256/SuY75QgkSNBlMtHNPeW9AayE7KNDAypMBHlJH9GEhXs=" + private const val PIN_BACKUP = "sha256/c4zbAoMygSbepJKqU3322FvFv5unm+TWZROW3FHU1o8=" + + /** + * Restricts [builder] to TLS 1.2 and pins the JudoPay certificate chain. + * + * @throws IllegalStateException if the platform exposes an unexpected default trust manager. + */ + fun applyTls12WithPinning(builder: OkHttpClient.Builder): OkHttpClient.Builder { + val sslContext = SSLContext.getInstance("TLSv1.2") + sslContext.init(null, null, null) + + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + + val trustManagers = trustManagerFactory.trustManagers + check(!(trustManagers.size != 1 || trustManagers.first() !is X509TrustManager)) { + "Unexpected default trust managers: ${trustManagers.contentToString()}" + } + + val trustManager = trustManagers.first() as X509TrustManager + + val specs = + listOf( + ConnectionSpec + .Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .build(), + ) + + return builder + .sslSocketFactory(Tls12SslSocketFactory(sslContext.socketFactory), trustManager) + .connectionSpecs(specs) + .certificatePinner( + CertificatePinner + .Builder() + .add(HOSTNAME_WILDCARD_PATTERN, PIN_PRIMARY, PIN_BACKUP) + .build(), + ) + } +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/RecommendationApiServiceFactory.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/RecommendationApiServiceFactory.kt index 067f7d2b2..d1850da67 100644 --- a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/RecommendationApiServiceFactory.kt +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/RecommendationApiServiceFactory.kt @@ -6,7 +6,6 @@ import com.google.gson.GsonBuilder import com.judopay.judokit.android.Judo import com.judopay.judokit.android.api.RecommendationApiService import com.judopay.judokit.android.api.interceptor.RecommendationHeadersInterceptor -import okhttp3.Interceptor import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit @@ -22,8 +21,6 @@ object RecommendationApiServiceFactory : ServiceFactory? = null - @Deprecated("Use create instead", replaceWith = ReplaceWith("create(context, judo)")) override fun createApiService( context: Context, diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/ServiceFactory.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/ServiceFactory.kt index deef102a2..f543cd174 100644 --- a/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/ServiceFactory.kt +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/factory/ServiceFactory.kt @@ -4,7 +4,6 @@ import android.content.Context import com.google.gson.Gson import com.judopay.judokit.android.Judo import com.judopay.judokit.android.api.interceptor.NetworkConnectivityInterceptor -import okhttp3.Interceptor import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -15,8 +14,6 @@ import retrofit2.converter.gson.GsonConverterFactory abstract class ServiceFactory { abstract val gson: Gson - abstract var externalInterceptors: List? - @Deprecated("Use create instead", ReplaceWith("create(context, judo)")) abstract fun createApiService( context: Context, @@ -60,10 +57,6 @@ abstract class ServiceFactory { } protected fun addExternalInterceptors(client: OkHttpClient.Builder) { - client.interceptors().apply { - externalInterceptors?.forEach { - add(it) - } - } + client.interceptors().addAll(JudoHttpInterceptors.interceptors) } } diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/interceptor/UserAgentInterceptor.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/interceptor/UserAgentInterceptor.kt new file mode 100644 index 000000000..cf7d0ecf1 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/interceptor/UserAgentInterceptor.kt @@ -0,0 +1,29 @@ +package com.judopay.judokit.android.api.interceptor + +import com.judopay.judokit.android.api.AppMetaDataProvider +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +private const val USER_AGENT_HEADER = "User-Agent" + +/** + * Sets the judo SDK [AppMetaDataProvider.userAgent] on requests that would otherwise be sent + * with OkHttp's default `User-Agent` — notably the DS-certificate CDN client, which does not + * run through [ApiHeadersInterceptor]. All other headers on the request are left untouched. + */ +internal class UserAgentInterceptor( + private val appMetaDataProvider: AppMetaDataProvider, +) : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val headers = + original.headers + .newBuilder() + .removeAll(USER_AGENT_HEADER) + .addUnsafeNonAscii(USER_AGENT_HEADER, appMetaDataProvider.userAgent) + .build() + return chain.proceed(original.newBuilder().headers(headers).build()) + } +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertEntry.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertEntry.kt new file mode 100644 index 000000000..19fd69df7 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertEntry.kt @@ -0,0 +1,42 @@ +package com.judopay.judokit.android.api.model.response.cdn + +import com.google.gson.annotations.SerializedName +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +internal data class DsCertEntry( + @SerializedName("dsId") val dsId: String, + @SerializedName("dsName") val dsName: String, + @SerializedName("dsCertificate") val dsCertificate: String, + @SerializedName("rootCertificates") val rootCertificates: List = emptyList(), + @SerializedName("keyId") val keyId: String, + @SerializedName("validUntil") val validUntil: String? = null, +) + +/** + * Parses [DsCertEntry.validUntil] (ISO-8601 UTC) into epoch millis, or `null` when it is absent + * or unparseable. Callers decide how to treat `null` (see [isNotExpired] / [isNearExpiry]). + */ +private fun DsCertEntry.validUntilEpochMillis(): Long? { + val until = validUntil ?: return null + return runCatching { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .parse(until) + ?.time + }.getOrNull() +} + +internal fun DsCertEntry.isNotExpired(now: Long): Boolean { + val epochMillis = validUntilEpochMillis() + return validUntil == null || (epochMillis != null && epochMillis > now) +} + +internal fun DsCertEntry.isNearExpiry( + now: Long, + thresholdMs: Long, +): Boolean { + val epochMillis = validUntilEpochMillis() ?: return false + return (epochMillis - now) < thresholdMs +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertsCache.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertsCache.kt new file mode 100644 index 000000000..2756257f1 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertsCache.kt @@ -0,0 +1,16 @@ +package com.judopay.judokit.android.api.model.response.cdn + +internal data class DsCertsCache( + val etag: String, + val lastModified: String, + val fetchedAt: Long, + val maxAgeMs: Long, + val entries: List, +) + +internal fun DsCertsCache.isFresh(now: Long): Boolean = (now - fetchedAt) < maxAgeMs + +internal fun DsCertsCache.hasNearExpiryEntry( + now: Long, + thresholdMs: Long, +): Boolean = entries.any { it.isNearExpiry(now, thresholdMs) } diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertsResponse.kt b/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertsResponse.kt new file mode 100644 index 000000000..7a6d014d8 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/api/model/response/cdn/DsCertsResponse.kt @@ -0,0 +1,15 @@ +package com.judopay.judokit.android.api.model.response.cdn + +import com.google.gson.annotations.SerializedName + +internal data class DsCertsResponse( + @SerializedName("schemaVersion") val schemaVersion: String, + @SerializedName("publishedAt") val publishedAt: String, + @SerializedName("etag") val etag: String, + @SerializedName("entries") val entries: List, +) + +internal fun String.isSupportedSchemaMajor(): Boolean { + val major = split(".").firstOrNull()?.toIntOrNull() ?: return false + return major == 1 +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/service/CardTransactionRepository.kt b/judokit-android/src/main/java/com/judopay/judokit/android/service/CardTransactionRepository.kt index 2b90dce17..60f3f32f5 100644 --- a/judokit-android/src/main/java/com/judopay/judokit/android/service/CardTransactionRepository.kt +++ b/judokit-android/src/main/java/com/judopay/judokit/android/service/CardTransactionRepository.kt @@ -36,6 +36,10 @@ import com.judopay.judokit.android.model.toSaveCardRequest import com.judopay.judokit.android.model.toTokenRequest import com.judopay.judokit.android.ui.common.getLocale import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import retrofit2.await /** @@ -102,16 +106,21 @@ internal data class ChallengeData( * @param judoApiService The Retrofit API service. * @param threeDS2Service The 3DS2 SDK service instance (not yet initialized). * @param recommendationService Service that may short-circuit 3DS2 via Ravelin. + * @param dsCertificateRepository Provides dynamically fetched DS certificates from the CDN + * cache. The [DsCertificateRepository.cachedEntry] lookup is cache-only — it never blocks + * the transaction path on the network. * @param resources Android [Resources], used for locale and error messages. * @param context Application context used for 3DS2 initialization/cleanup. */ @Suppress("TooManyFunctions") internal class CardTransactionRepository + @Suppress("LongParameterList") internal constructor( private val judo: Judo, private val judoApiService: JudoApiService, private val threeDS2Service: ThreeDS2Service, private val recommendationService: RecommendationService, + private val dsCertificateRepository: DsCertificateRepository, private val resources: Resources, private val context: Context, ) { @@ -230,6 +239,8 @@ internal class CardTransactionRepository val network = details.cardType ?: CardNetwork.OTHER val directoryServerId = resolveDirectoryServerId(network) + + threeDS2Service.setCertificateProvider(JudoDsCertificateProvider(dsCertificateRepository)) val transaction = threeDS2Service.createTransaction(directoryServerId, judo.threeDSTwoMessageVersion) return try { @@ -370,11 +381,16 @@ internal class CardTransactionRepository judo: Judo, ): CardTransactionRepository { val appContext = context.applicationContext + val dsCertificateRepository = DsCertificateRepository.getInstance(appContext, judo) + CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + dsCertificateRepository.prefetch() + } return CardTransactionRepository( judo = judo, judoApiService = JudoApiServiceFactory.create(appContext, judo), threeDS2Service = ThreeDS2ServiceImpl(), recommendationService = RecommendationService(appContext, judo), + dsCertificateRepository = dsCertificateRepository, resources = appContext.resources, context = appContext, ) diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertEntryExtensions.kt b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertEntryExtensions.kt new file mode 100644 index 000000000..82b4e26b2 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertEntryExtensions.kt @@ -0,0 +1,86 @@ +package com.judopay.judokit.android.service + +import android.util.Base64 +import com.judopay.judo3ds2.certificate.Algorithm +import com.judopay.judo3ds2.certificate.DirectoryServerCertificateProvider +import com.judopay.judo3ds2.certificate.DsCertificateMaterial +import com.judopay.judokit.android.api.model.response.cdn.DsCertEntry +import java.security.KeyFactory +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.X509EncodedKeySpec + +internal fun DsCertEntry.toMaterial(): DsCertificateMaterial? { + val alg = dsCertificate.deriveDsAlgorithm() ?: return null + return DsCertificateMaterial( + publicKeyPem = dsCertificate, + keyId = keyId, + algorithm = alg, + rootCertificatesPem = rootCertificates, + ) +} + +/** + * Derives the AReq encryption [Algorithm] from the DS public-key material so the CDN payload does + * not have to carry (and keep consistent) a redundant `algorithm` field. The encryption scheme is + * a pure function of the key type — an RSA key implies RSA-OAEP, an EC key implies ECDH-ES. + * + * Mirrors the SDK's own parse paths (`OverrideCertificatePublicKeyProvider`): a full X.509 + * certificate exposes its key type directly, while a bare `SubjectPublicKeyInfo` is matched against + * the supported key factories. Returns `null` when the material cannot be parsed or the key type is + * unsupported, in which case [toMaterial] yields `null` and the caller falls back to the SDK's + * built-in certificate. + */ +private fun String.deriveDsAlgorithm(): Algorithm? { + val trimmed = trim() + return if (trimmed.startsWith("-----BEGIN CERTIFICATE-----")) { + trimmed.certificateKeyAlgorithm() + } else { + trimmed.rawKeyAlgorithm() + } +} + +private fun String.certificateKeyAlgorithm(): Algorithm? = + runCatching { + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(byteInputStream()) as X509Certificate + cert.publicKey.algorithm.toDsAlgorithm() + }.getOrNull() + +/** + * A bare `SubjectPublicKeyInfo` carries its algorithm OID in the DER, but the JDK's [KeyFactory] + * needs the key type up front — so probe the supported types and keep the one that parses. + */ +private fun String.rawKeyAlgorithm(): Algorithm? { + val der = stripPemArmorToDer() ?: return null + return Algorithm.entries.firstOrNull { alg -> + runCatching { + KeyFactory.getInstance(alg.name).generatePublic(X509EncodedKeySpec(der)) + }.isSuccess + } +} + +private fun String.stripPemArmorToDer(): ByteArray? = + runCatching { + val body = + lineSequence() + .filterNot { it.startsWith("-----BEGIN") || it.startsWith("-----END") } + .joinToString(separator = "") + .trim() + Base64.decode(body, Base64.DEFAULT) + }.getOrNull() + +private fun String.toDsAlgorithm(): Algorithm? = + when (uppercase()) { + "RSA" -> Algorithm.RSA + "EC", "ECDSA" -> Algorithm.EC + else -> null + } + +internal class JudoDsCertificateProvider( + private val repo: DsCertificateRepository, +) : DirectoryServerCertificateProvider { + override fun certificate(directoryServerId: String): DsCertificateMaterial? = repo.cachedEntry(directoryServerId)?.toMaterial() +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertificateCache.kt b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertificateCache.kt new file mode 100644 index 000000000..cead82e63 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertificateCache.kt @@ -0,0 +1,28 @@ +package com.judopay.judokit.android.service + +import android.content.Context + +/** + * Public entry point for managing the on-device cache of 3-D Secure 2 directory-server (DS) + * certificates that the SDK fetches from the Judo CDN. + * + * The SDK keeps these certificates in a small `SharedPreferences` store and refreshes them + * automatically in the background at payment-screen creation. Clearing the cache is normally + * unnecessary and is provided mainly for QA and support scenarios that require a forced + * re-fetch on the next transaction. + */ +object DsCertificateCache { + /** + * Removes every locally cached DS certificate. + * + * After clearing, the next card transaction falls back to the 3DS SDK's built-in + * certificates until the background refresh re-populates the cache from the CDN. Safe to + * call from any thread. + * + * @param context any [Context]; the application context is used internally. + */ + @JvmStatic + fun clear(context: Context) { + DsCertsCacheStore(context.applicationContext).clear() + } +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertificateRepository.kt b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertificateRepository.kt new file mode 100644 index 000000000..3e5020f2a --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertificateRepository.kt @@ -0,0 +1,137 @@ +package com.judopay.judokit.android.service + +import android.content.Context +import android.util.Log +import com.judopay.judokit.android.Judo +import com.judopay.judokit.android.api.DsCdnApiService +import com.judopay.judokit.android.api.factory.DsCdnApiServiceFactory +import com.judopay.judokit.android.api.model.response.cdn.DsCertEntry +import com.judopay.judokit.android.api.model.response.cdn.DsCertsCache +import com.judopay.judokit.android.api.model.response.cdn.hasNearExpiryEntry +import com.judopay.judokit.android.api.model.response.cdn.isFresh +import com.judopay.judokit.android.api.model.response.cdn.isNotExpired +import com.judopay.judokit.android.api.model.response.cdn.isSupportedSchemaMajor +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val CDN_URL = "judokit/ds-certs" +private const val DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L +private const val PRE_EXPIRY_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000L +private const val HTTP_NOT_MODIFIED = 304 +private const val MILLIS_PER_SECOND = 1000L +private const val HEADER_ETAG = "ETag" +private const val HEADER_LAST_MODIFIED = "Last-Modified" +private const val HEADER_CACHE_CONTROL = "Cache-Control" +private val TAG = DsCertificateRepository::class.java.simpleName + +internal class DsCertificateRepository( + private val api: DsCdnApiService, + private val cache: DsCertsCacheStore, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val mutex = Mutex() + + /** + * Best-effort background refresh. Call at payment-screen creation, off the critical path. + * All errors are swallowed and logged, except [CancellationException], which is rethrown + * so coroutine cancellation propagates as normal. + */ + suspend fun prefetch() { + runCatching { refresh() }.onFailure { + if (it is CancellationException) throw it + } + } + + /** + * Transaction-path lookup: reads the local cache only, never blocks on the network. + * Returns null when no valid cached entry exists for [dsId]; the caller must fall back + * to the 3DS SDK's built-in certificate. + */ + fun cachedEntry(dsId: String): DsCertEntry? { + val now = clock() + return cache.read()?.entries?.firstOrNull { it.dsId == dsId && it.isNotExpired(now) } + } + + /** + * Drops all locally cached DS certificates. The next [prefetch] re-populates the cache from + * the CDN; until then [cachedEntry] returns null and callers fall back to the 3DS SDK's + * built-in certificates. Intended for QA/support scenarios that need a forced re-fetch. + */ + fun clearCache() = cache.clear() + + private suspend fun refresh() = + mutex.withLock { + // only one refresh runs at a time + val now = clock() + val current = cache.read() + if (current != null && + current.isFresh(now) && + !current.hasNearExpiryEntry(now, PRE_EXPIRY_THRESHOLD_MS) + ) { + return@withLock + } + + val response = + api.fetchDsCerts( + url = CDN_URL, + ifNoneMatch = current?.etag?.ifBlank { null }, + ifModifiedSince = current?.lastModified?.ifBlank { null }, + ) + + when { + response.code() == HTTP_NOT_MODIFIED -> + current?.let { cache.write(it.copy(fetchedAt = now)) } + + response.isSuccessful -> { + val body = response.body() ?: return@withLock + if (!body.schemaVersion.isSupportedSchemaMajor()) { + return@withLock + } + if (body.entries.isEmpty()) { + return@withLock + } + cache.write( + DsCertsCache( + etag = response.headers()[HEADER_ETAG] ?: body.etag, + lastModified = response.headers()[HEADER_LAST_MODIFIED].orEmpty(), + fetchedAt = now, + maxAgeMs = parseMaxAgeMs(response.headers()[HEADER_CACHE_CONTROL]), + entries = body.entries, + ), + ) + } + + else -> { + // noop + // DS cert CDN fetch returned ${response.code()} — keeping existing cache + } + } + } + + private fun parseMaxAgeMs(cacheControl: String?): Long { + val seconds = + cacheControl + ?.let { Regex("""max-age=(\d+)""").find(it) } + ?.groupValues + ?.get(1) + ?.toLongOrNull() + return seconds?.times(MILLIS_PER_SECOND) ?: DEFAULT_MAX_AGE_MS + } + + companion object { + @Volatile + private var instance: DsCertificateRepository? = null + + fun getInstance( + context: Context, + judo: Judo, + ): DsCertificateRepository = + instance ?: synchronized(this) { + instance ?: DsCertificateRepository( + api = DsCdnApiServiceFactory.create(context.applicationContext, judo), + cache = DsCertsCacheStore(context.applicationContext), + ).also { instance = it } + } + } +} diff --git a/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertsCacheStore.kt b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertsCacheStore.kt new file mode 100644 index 000000000..0a2abaaa5 --- /dev/null +++ b/judokit-android/src/main/java/com/judopay/judokit/android/service/DsCertsCacheStore.kt @@ -0,0 +1,30 @@ +package com.judopay.judokit.android.service + +import android.content.Context +import androidx.core.content.edit +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.judopay.judokit.android.api.model.response.cdn.DsCertsCache + +private const val PREFS_NAME = "judokit_ds_certs" +private const val KEY_CACHE = "cache_v1" + +internal class DsCertsCacheStore( + context: Context, + private val gson: Gson = GsonBuilder().create(), +) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun read(): DsCertsCache? { + val json = prefs.getString(KEY_CACHE, null) ?: return null + return runCatching { gson.fromJson(json, DsCertsCache::class.java) }.getOrNull() + } + + fun write(cache: DsCertsCache) { + prefs.edit { putString(KEY_CACHE, gson.toJson(cache)) } + } + + fun clear() { + prefs.edit { remove(KEY_CACHE) } + } +} diff --git a/judokit-android/src/test/java/com/judopay/judokit/android/api/interceptor/UserAgentInterceptorTest.kt b/judokit-android/src/test/java/com/judopay/judokit/android/api/interceptor/UserAgentInterceptorTest.kt new file mode 100644 index 000000000..392cf741a --- /dev/null +++ b/judokit-android/src/test/java/com/judopay/judokit/android/api/interceptor/UserAgentInterceptorTest.kt @@ -0,0 +1,87 @@ +package com.judopay.judokit.android.api.interceptor + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import com.judopay.judokit.android.api.AppMetaDataProvider +import com.judopay.judokit.android.model.SubProductInfo +import com.judopay.judokit.android.ui.common.JUDO_KIT_VERSION +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +internal class UserAgentInterceptorTest { + private val okHttpClient = OkHttpClient.Builder() + private val mockWebServer = MockWebServer() + + @BeforeEach + internal fun setUp() { + mockWebServer.start() + mockWebServer.enqueue(MockResponse()) + + mockkObject(AppMetaDataProvider.SystemInfo) + every { AppMetaDataProvider.SystemInfo.androidVersionString } returns "13.0" + every { AppMetaDataProvider.SystemInfo.deviceManufacturer } returns "Google" + every { AppMetaDataProvider.SystemInfo.deviceModel } returns "Pixel 7" + + okHttpClient.addInterceptor(UserAgentInterceptor(createAppMetadataProvider())) + } + + @AfterEach + internal fun tearDown() { + mockWebServer.shutdown() + } + + @DisplayName("Given request is intercepted, then set the judo SDK user agent header") + @Test + fun setsJudoUserAgentHeader() { + val recordedRequest = makeRequest() + + assertEquals( + "JudoKit-Android/$JUDO_KIT_VERSION Android/13.0 Test application/1.0 Google Pixel 7", + recordedRequest.getHeader("User-Agent"), + ) + } + + @DisplayName("Given the request already carries a user agent, then it is replaced") + @Test + fun replacesExistingUserAgentHeader() { + val recordedRequest = makeRequest(userAgent = "okhttp/4.12.0") + + assertEquals( + "JudoKit-Android/$JUDO_KIT_VERSION Android/13.0 Test application/1.0 Google Pixel 7", + recordedRequest.getHeader("User-Agent"), + ) + } + + private fun makeRequest(userAgent: String? = null): RecordedRequest { + val request = + Request + .Builder() + .url(mockWebServer.url("/")) + .apply { userAgent?.let { header("User-Agent", it) } } + .build() + okHttpClient.build().newCall(request).execute() + return mockWebServer.takeRequest() + } + + private fun createAppMetadataProvider(subProductInfo: SubProductInfo = SubProductInfo.Unknown): AppMetaDataProvider { + val packageManagerMock = mockk(relaxed = true) + every { packageManagerMock.getApplicationLabel(any()) } returns "Test application" + every { packageManagerMock.getPackageInfo(any(), 0) } returns + PackageInfo().apply { versionName = "1.0" } + val mockContext = mockk(relaxed = true) + every { mockContext.applicationContext.packageManager } returns packageManagerMock + return AppMetaDataProvider(mockContext, subProductInfo) + } +} diff --git a/judokit-android/src/test/java/com/judopay/judokit/android/service/CardTransactionRepositoryTest.kt b/judokit-android/src/test/java/com/judopay/judokit/android/service/CardTransactionRepositoryTest.kt index 414c65c66..b1543eb5c 100644 --- a/judokit-android/src/test/java/com/judopay/judokit/android/service/CardTransactionRepositoryTest.kt +++ b/judokit-android/src/test/java/com/judopay/judokit/android/service/CardTransactionRepositoryTest.kt @@ -20,11 +20,13 @@ import com.judopay.judokit.android.model.TransactionDetails import com.judopay.judokit.android.model.toPaymentRequest import com.judopay.judokit.android.model.toPreAuthTokenRequest import com.judopay.judokit.android.model.toTokenRequest +import com.judopay.judokit.android.service.DsCertificateRepository import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -44,6 +46,7 @@ internal class CardTransactionRepositoryTest { private val judoApiService: JudoApiService = mockk(relaxed = true) private val threeDS2Service: ThreeDS2Service = mockk(relaxed = true) private val recommendationService: RecommendationService = mockk(relaxed = true) + private val dsCertificateRepository: DsCertificateRepository = mockk(relaxed = true) private val resources: Resources = mockk(relaxed = true) private val context: Context = mockk(relaxed = true) private val transaction: Transaction = mockk(relaxed = true) @@ -61,7 +64,16 @@ internal class CardTransactionRepositoryTest { every { recommendationService.isRecommendationFeatureAvailable(any()) } returns false every { threeDS2Service.createTransaction(any(), any()) } returns transaction - sut = CardTransactionRepository(judo, judoApiService, threeDS2Service, recommendationService, resources, context) + sut = + CardTransactionRepository( + judo, + judoApiService, + threeDS2Service, + recommendationService, + dsCertificateRepository, + resources, + context, + ) } @AfterEach @@ -260,6 +272,24 @@ internal class CardTransactionRepositoryTest { } } + @Nested + @DisplayName("DS certificate provider") + inner class DsCertificateProviderTests { + @DisplayName("setCertificateProvider is called before createTransaction") + @Test + fun registersProviderBeforeCreateTransaction() = + runTest { + val details: TransactionDetails = mockk(relaxed = true) + val call: Call> = mockk(relaxed = true) + every { judoApiService.payment(any()) } returns call + coEvery { call.await() } returns JudoApiCallResult.Failure() + + sut.payment(details) { _, _ -> null } + + verify { threeDS2Service.setCertificateProvider(any()) } + } + } + @Nested @DisplayName("recommendation service integration") inner class RecommendationTests { diff --git a/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertEntryExtensionsTest.kt b/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertEntryExtensionsTest.kt new file mode 100644 index 000000000..bce02e8b5 --- /dev/null +++ b/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertEntryExtensionsTest.kt @@ -0,0 +1,120 @@ +package com.judopay.judokit.android.service + +import com.judopay.judo3ds2.certificate.Algorithm +import com.judopay.judokit.android.api.model.response.cdn.DsCertEntry +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("Testing DsCertEntryExtensions") +internal class DsCertEntryExtensionsTest { + private fun entry(certificate: String = RSA_CERT_PEM) = + DsCertEntry( + dsId = "A000000003", + dsName = "Visa", + dsCertificate = certificate, + rootCertificates = listOf("rootpem=="), + keyId = "747da056-476c-4296-a7c4-7e853e235ef0", + validUntil = "2099-01-01T00:00:00Z", + ) + + @Nested + @DisplayName("toMaterial") + inner class ToMaterialTests { + @Test + @DisplayName("maps fields and derives RSA from an RSA certificate") + fun mapsRsaEntry() { + val material = entry(RSA_CERT_PEM).toMaterial() + assertNotNull(material) + assertEquals("747da056-476c-4296-a7c4-7e853e235ef0", material!!.keyId) + assertEquals(RSA_CERT_PEM, material.publicKeyPem) + assertEquals(Algorithm.RSA, material.algorithm) + assertEquals(listOf("rootpem=="), material.rootCertificatesPem) + } + + @Test + @DisplayName("derives EC from an EC certificate") + fun mapsEcEntry() { + val material = entry(EC_CERT_PEM).toMaterial() + assertNotNull(material) + assertEquals(Algorithm.EC, material!!.algorithm) + } + + @Test + @DisplayName("returns null when the certificate material cannot be parsed") + fun returnsNullForUnparseableCertificate() { + assertNull(entry("not-a-certificate").toMaterial()) + } + } + + @Nested + @DisplayName("JudoDsCertificateProvider") + inner class JudoDsCertificateProviderTests { + @Test + @DisplayName("returns material when cache has a valid entry") + fun returnsMaterialOnCacheHit() { + val repo: DsCertificateRepository = mockk() + every { repo.cachedEntry("A000000003") } returns entry(RSA_CERT_PEM) + + assertNotNull(JudoDsCertificateProvider(repo).certificate("A000000003")) + } + + @Test + @DisplayName("returns null when cache has no entry for dsId") + fun returnsNullOnCacheMiss() { + val repo: DsCertificateRepository = mockk() + every { repo.cachedEntry(any()) } returns null + + assertNull(JudoDsCertificateProvider(repo).certificate("A000000003")) + } + + @Test + @DisplayName("returns null when the cached entry's certificate cannot be parsed") + fun returnsNullForUnparseableCertificate() { + val repo: DsCertificateRepository = mockk() + every { repo.cachedEntry(any()) } returns entry("not-a-certificate") + + assertNull(JudoDsCertificateProvider(repo).certificate("A000000003")) + } + } + + private companion object { + val RSA_CERT_PEM = + """ + -----BEGIN CERTIFICATE----- + MIICqjCCAZICCQDzSXtV7rVYwzANBgkqhkiG9w0BAQsFADAWMRQwEgYDVQQDDAtU + ZXN0IFJTQSBEUzAgFw0yNjA2MTkyMzAyNTlaGA8yMTI2MDUyNjIzMDI1OVowFjEU + MBIGA1UEAwwLVGVzdCBSU0EgRFMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK + AoIBAQCtZvg9B3x6A1JucTOSi5YoneJMHsgak57dHX11OiPeDDJdqMSrsMSB3c/M + /pqZ3H9NWC4vGax63vmUJQXTxXcrtoS0TcSfdvZe8tMO5ak/IvlXuHk11y3qiM33 + +/2zGoGhoEW29nUI+ykYBLTFXcbi79Fxch6IOSswt928x0qoanMlXeCUH21icBQf + sZXr59X5CPwiYi1PSTCeSgnf6KYCLyfATG4sTZScrFc1E//uJZ9WtEVifiT6S7v7 + gyJBWc+3YYkvjOE5JmzAwg4xHMdaXKHFOA7YQhYX++BxUp9ga3KiQyh5XKtkJ+wQ + QW1reekddzt5k0GSmScxBVDYbAEdAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAE21 + UFwQe+8SghGFNb4rC9Kq68vvLWNPpKDY9t/m7zTyxYhlnOVTqAO5mvgVYoD/KuqN + CY/MTCo7/ymS+OsPnpjkqdSd1QbI27+zySEPAz4A0ENISmwVQsYOUA4p2tqMeq7o + PkFDL0ZsRHL9P3PjbhyMIw0Sqv+g8AKTdNrecQJ4mPkTUygj3gz2cg/k/jy/vLcl + Apw991MwOS16ftKlUmnu+y8NPMXtn/DuwQ6F7lN6N6eFjjLZdgCPsIex6crgPGlY + tNT6bTSzrYCiqoK3mN91moMoQvUeL/8OcqtcBQh7BMaNbeylPMucAT6UZx9lDXvN + y1L577h4oVJILCoZeS0= + -----END CERTIFICATE----- + """.trimIndent() + + val EC_CERT_PEM = + """ + -----BEGIN CERTIFICATE----- + MIIBGjCBwgIJALPuv2RtTJOBMAoGCCqGSM49BAMCMBUxEzARBgNVBAMMClRlc3Qg + RUMgRFMwIBcNMjYwNjE5MjMwMzE5WhgPMjEyNjA1MjYyMzAzMTlaMBUxEzARBgNV + BAMMClRlc3QgRUMgRFMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQlzf+gpaIA + uG2GTyg+mUfd5SscY7itmVboH9A44e6O5fHQ+eYopDca2RJbbivj4IEiXJZePlKf + rQtRZ/2Hz75oMAoGCCqGSM49BAMCA0cAMEQCID1dgEJcIugQZ29hXdyXMZgtLohT + IIDOQCkm7lsUjov4AiAo2BAFdYRb0i8dKzHB8omiZPmKhG9WcSURpFMyThEp0A== + -----END CERTIFICATE----- + """.trimIndent() + } +} diff --git a/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertificateRepositoryTest.kt b/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertificateRepositoryTest.kt new file mode 100644 index 000000000..cccae5e6c --- /dev/null +++ b/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertificateRepositoryTest.kt @@ -0,0 +1,347 @@ +package com.judopay.judokit.android.service + +import com.judopay.judokit.android.api.DsCdnApiService +import com.judopay.judokit.android.api.model.response.cdn.DsCertEntry +import com.judopay.judokit.android.api.model.response.cdn.DsCertsCache +import com.judopay.judokit.android.api.model.response.cdn.DsCertsResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import okhttp3.Headers +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import retrofit2.Response + +@ExperimentalCoroutinesApi +@DisplayName("Testing DsCertificateRepository") +internal class DsCertificateRepositoryTest { + private val api: DsCdnApiService = mockk() + private val cache: DsCertsCacheStore = mockk(relaxed = true) + + // Nov 14, 2023 22:13:20 UTC + private var now = 1_700_000_000_000L + private val clock: () -> Long = { now } + + private lateinit var sut: DsCertificateRepository + + @BeforeEach + fun setUp() { + sut = DsCertificateRepository(api, cache, clock) + } + + @Nested + @DisplayName("clearCache") + inner class ClearCacheTests { + @Test + @DisplayName("delegates to the cache store") + fun delegatesToCacheStore() { + sut.clearCache() + + verify { cache.clear() } + } + } + + @Nested + @DisplayName("cachedEntry") + inner class CachedEntryTests { + @Test + @DisplayName("returns null when cache is empty") + fun returnsNullWhenCacheEmpty() { + every { cache.read() } returns null + + assertNull(sut.cachedEntry("A000000003")) + } + + @Test + @DisplayName("returns null when dsId is not in cache") + fun returnsNullForUnknownDsId() { + every { cache.read() } returns aCache(entries = listOf(visaEntry())) + + assertNull(sut.cachedEntry("A000000004")) + } + + @Test + @DisplayName("returns null for expired entry") + fun returnsNullForExpiredEntry() { + // 2020-01-01 is in the past relative to now (Nov 2023) + val expired = visaEntry(validUntil = "2020-01-01T00:00:00Z") + every { cache.read() } returns aCache(entries = listOf(expired)) + + assertNull(sut.cachedEntry("A000000003")) + } + + @Test + @DisplayName("returns entry for valid non-expired cache hit") + fun returnsEntryForValidCacheHit() { + val entry = visaEntry(validUntil = "2099-01-01T00:00:00Z") + every { cache.read() } returns aCache(entries = listOf(entry)) + + assertEquals(entry, sut.cachedEntry("A000000003")) + } + + @Test + @DisplayName("returns entry when validUntil is absent (no expiry)") + fun returnsEntryWhenValidUntilAbsent() { + val entry = visaEntry(validUntil = null) + every { cache.read() } returns aCache(entries = listOf(entry)) + + assertNotNull(sut.cachedEntry("A000000003")) + } + } + + @Nested + @DisplayName("prefetch") + inner class PrefetchTests { + @Test + @DisplayName("does not call API when cache is fresh and no entry is near expiry") + fun skipsApiCallWhenCacheIsFresh() = + runTest { + every { cache.read() } returns aFreshCache() + + sut.prefetch() + + coVerify(exactly = 0) { api.fetchDsCerts(any(), any(), any()) } + } + + @Test + @DisplayName("calls API with If-None-Match when cache is stale") + fun callsApiWithETagWhenCacheStale() = + runTest { + every { cache.read() } returns aStaleCache() + coEvery { api.fetchDsCerts(any(), "v2025-03-01-abc123", any()) } returns notModifiedResponse() + + sut.prefetch() + + coVerify(exactly = 1) { api.fetchDsCerts(any(), "v2025-03-01-abc123", any()) } + } + + @Test + @DisplayName("bumps fetchedAt on 304 without replacing entries") + fun bumpsFetchedAtOn304() = + runTest { + val stale = aStaleCache() + every { cache.read() } returns stale + coEvery { api.fetchDsCerts(any(), any(), any()) } returns notModifiedResponse() + + now = 9_999_999_999L + sut.prefetch() + + verify { + cache.write(match { it.fetchedAt == 9_999_999_999L && it.entries === stale.entries }) + } + } + + @Test + @DisplayName("replaces cache on 200 with valid schema version") + fun replacesCacheOn200() = + runTest { + every { cache.read() } returns aStaleCache() + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse(body = aResponse(schemaVersion = "1.0")) + + sut.prefetch() + + verify { cache.write(any()) } + } + + @Test + @DisplayName("rejects payload with unsupported major schemaVersion") + fun rejectsUnsupportedSchemaVersion() = + runTest { + every { cache.read() } returns aStaleCache() + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse(body = aResponse(schemaVersion = "10.0")) + + sut.prefetch() + + verify(exactly = 0) { cache.write(any()) } + } + + @Test + @DisplayName("keeps existing cache when 200 response contains no entries") + fun keepsCacheWhenResponseHasNoEntries() = + runTest { + every { cache.read() } returns aStaleCache() + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse(body = aResponse(entries = emptyList())) + + sut.prefetch() + + verify(exactly = 0) { cache.write(any()) } + } + + @Test + @DisplayName("omits conditional headers when cached etag and lastModified are blank") + fun omitsConditionalHeadersWhenBlank() = + runTest { + every { cache.read() } returns aCache(etag = "", lastModified = "", fetchedAt = 0L) + coEvery { api.fetchDsCerts(any(), isNull(), isNull()) } returns + successResponse(body = aResponse()) + + sut.prefetch() + + coVerify(exactly = 1) { api.fetchDsCerts(any(), isNull(), isNull()) } + } + + @Test + @DisplayName("does not update cache on non-200 / non-304 response") + fun doesNotUpdateCacheOnErrorResponse() = + runTest { + every { cache.read() } returns aStaleCache() + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + Response.error(500, "server error".toResponseBody()) + + sut.prefetch() + + verify(exactly = 0) { cache.write(any()) } + } + + @Test + @DisplayName("swallows network exception and does not crash") + fun swallowsNetworkException() = + runTest { + every { cache.read() } returns null + coEvery { api.fetchDsCerts(any(), any(), any()) } throws RuntimeException("timeout") + + sut.prefetch() // must not throw + } + + @Test + @DisplayName("triggers refresh when cache has a near-expiry entry despite fresh TTL") + fun triggersRefreshForNearExpiryEntry() = + runTest { + // Entry expired in 2020 — well within the 7-day pre-expiry window + val nearExpiry = visaEntry(validUntil = "2020-01-01T00:00:00Z") + // fetchedAt = now → cache TTL is fresh, but entry is near expiry + every { cache.read() } returns + aCache(fetchedAt = now, maxAgeMs = 86_400_000L, entries = listOf(nearExpiry)) + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse(body = aResponse()) + + sut.prefetch() + + coVerify(exactly = 1) { api.fetchDsCerts(any(), any(), any()) } + } + + @Test + @DisplayName("concurrent prefetch calls only trigger one network request") + fun concurrentPrefetchOnlyOneNetworkCall() = + runTest { + // Track cache state so the second job sees a fresh cache after the first write + var stored: DsCertsCache? = null + every { cache.read() } answers { stored } + every { cache.write(any()) } answers { stored = firstArg() } + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse(body = aResponse()) + + val job1 = launch { sut.prefetch() } + val job2 = launch { sut.prefetch() } + job1.join() + job2.join() + + coVerify(atMost = 1) { api.fetchDsCerts(any(), any(), any()) } + } + } + + @Nested + @DisplayName("Cache-Control max-age parsing") + inner class CacheControlTests { + @Test + @DisplayName("honours max-age from Cache-Control header") + fun honoursMaxAgeFromHeader() = + runTest { + every { cache.read() } returns aStaleCache() + var writtenCache: DsCertsCache? = null + every { cache.write(any()) } answers { writtenCache = firstArg() } + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse( + headers = Headers.headersOf("Cache-Control", "max-age=3600", "ETag", "v2"), + body = aResponse(), + ) + + sut.prefetch() + + assertEquals(3_600_000L, writtenCache?.maxAgeMs) + } + + @Test + @DisplayName("falls back to 24 h default when Cache-Control header is absent") + fun fallsBackTo24hWhenHeaderAbsent() = + runTest { + every { cache.read() } returns aStaleCache() + var writtenCache: DsCertsCache? = null + every { cache.write(any()) } answers { writtenCache = firstArg() } + coEvery { api.fetchDsCerts(any(), any(), any()) } returns + successResponse(body = aResponse()) + + sut.prefetch() + + assertEquals(86_400_000L, writtenCache?.maxAgeMs) + } + } + + private fun visaEntry(validUntil: String? = "2099-01-01T00:00:00Z") = + DsCertEntry( + dsId = "A000000003", + dsName = "Visa", + dsCertificate = "base64pem==", + rootCertificates = listOf("rootpem=="), + keyId = "747da056-476c-4296-a7c4-7e853e235ef0", + validUntil = validUntil, + ) + + private fun aCache( + etag: String = "v2025-03-01-abc123", + lastModified: String = "Tue, 01 Mar 2025 00:00:00 GMT", + fetchedAt: Long = now, + maxAgeMs: Long = 86_400_000L, + entries: List = listOf(visaEntry()), + ) = DsCertsCache( + etag = etag, + lastModified = lastModified, + fetchedAt = fetchedAt, + maxAgeMs = maxAgeMs, + entries = entries, + ) + + // fetchedAt = now → (now - now) = 0 < maxAgeMs → fresh + private fun aFreshCache() = aCache(fetchedAt = now, maxAgeMs = 86_400_000L) + + // fetchedAt = 0 → (now - 0) >> maxAgeMs → stale + private fun aStaleCache() = aCache(fetchedAt = 0L, maxAgeMs = 86_400_000L) + + private fun aResponse( + schemaVersion: String = "1.0", + entries: List = listOf(visaEntry()), + ) = DsCertsResponse( + schemaVersion = schemaVersion, + publishedAt = "2025-03-01T00:00:00Z", + etag = "v2025-03-01-abc123", + entries = entries, + ) + + private fun successResponse( + headers: Headers = Headers.headersOf("ETag", "v2025-03-01-abc123"), + body: DsCertsResponse, + ): Response = Response.success(body, headers) + + /** Creates a retrofit2.Response that reports HTTP 304 Not Modified. */ + private fun notModifiedResponse(): Response = + mockk { + every { code() } returns 304 + every { isSuccessful } returns false + every { body() } returns null + every { headers() } returns Headers.headersOf() + } +} diff --git a/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertsCacheStoreTest.kt b/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertsCacheStoreTest.kt new file mode 100644 index 000000000..ac858701f --- /dev/null +++ b/judokit-android/src/test/java/com/judopay/judokit/android/service/DsCertsCacheStoreTest.kt @@ -0,0 +1,115 @@ +package com.judopay.judokit.android.service + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.GsonBuilder +import com.judopay.judokit.android.api.model.response.cdn.DsCertEntry +import com.judopay.judokit.android.api.model.response.cdn.DsCertsCache +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Testing DsCertsCacheStore") +internal class DsCertsCacheStoreTest { + private val prefs: SharedPreferences = mockk(relaxed = true) + private val editor: SharedPreferences.Editor = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val gson = GsonBuilder().create() + + private lateinit var sut: DsCertsCacheStore + + @BeforeEach + fun setUp() { + every { context.getSharedPreferences(any(), any()) } returns prefs + every { prefs.edit() } returns editor + every { editor.putString(any(), any()) } returns editor + + sut = DsCertsCacheStore(context, gson) + } + + @Test + @DisplayName("read returns null when no cached value exists") + fun readReturnsNullWhenNoCachedValue() { + every { prefs.getString("cache_v1", null) } returns null + + assertNull(sut.read()) + } + + @Test + @DisplayName("read returns null when JSON is corrupt") + fun readReturnsNullForCorruptJson() { + every { prefs.getString("cache_v1", null) } returns "not-valid-json{{{{" + + assertNull(sut.read()) + } + + @Test + @DisplayName("write serialises cache and persists to SharedPreferences") + fun writeSerialisesCacheAndPersists() { + val jsonSlot = slot() + every { editor.putString("cache_v1", capture(jsonSlot)) } returns editor + + sut.write(aCache()) + + verify { editor.apply() } + val written = jsonSlot.captured + assert(written.contains("A000000003")) + assert(written.contains("v2025-03-01")) + } + + @Test + @DisplayName("round-trip write then read returns equal cache") + fun roundTripWriteThenRead() { + val original = aCache() + var stored: String? = null + + every { editor.putString("cache_v1", any()) } answers { + stored = secondArg() + editor + } + every { prefs.getString("cache_v1", null) } answers { stored } + + sut.write(original) + val restored = sut.read() + + assertNotNull(restored) + assertEquals(original, restored) + } + + @Test + @DisplayName("clear removes the cached value from SharedPreferences") + fun clearRemovesCachedValue() { + every { editor.remove("cache_v1") } returns editor + + sut.clear() + + verify { editor.remove("cache_v1") } + verify { editor.apply() } + } + + private fun aCache() = + DsCertsCache( + etag = "v2025-03-01-abc123", + lastModified = "Tue, 01 Mar 2025 00:00:00 GMT", + fetchedAt = 1_000_000L, + maxAgeMs = 86_400_000L, + entries = + listOf( + DsCertEntry( + dsId = "A000000003", + dsName = "Visa", + dsCertificate = "base64pem==", + rootCertificates = listOf("rootpem=="), + keyId = "747da056-476c-4296-a7c4-7e853e235ef0", + validUntil = "2026-03-01T00:00:00Z", + ), + ), + ) +} diff --git a/scripts/simulate-ds-certs-304.sh b/scripts/simulate-ds-certs-304.sh new file mode 100755 index 000000000..b5d2824f5 --- /dev/null +++ b/scripts/simulate-ds-certs-304.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# simulate-ds-certs-304.sh +# +# Ages the SDK's cached 3DS2 DS-certificate bundle so the next payment-screen +# prefetch treats it as stale and issues a *conditional* request +# (If-None-Match / If-Modified-Since). If the CDN content is unchanged the +# server answers 304 Not Modified and the repository just bumps `fetchedAt` +# while keeping the cached entries — which is the path this script lets you +# exercise on a real device/emulator without waiting out the 24h TTL. +# +# Requires: a *debuggable* build of the examples app installed, and adb on PATH. +# Pure bash + adb — no perl/python/jq. +# +# Usage: +# scripts/simulate-ds-certs-304.sh # age cache (fetchedAt = 0) +# scripts/simulate-ds-certs-304.sh 1700000000000 # set fetchedAt to given epoch-ms +# scripts/simulate-ds-certs-304.sh show # print current cache metadata +# PKG=com.example.app scripts/simulate-ds-certs-304.sh +# +set -euo pipefail + +PKG="${PKG:-com.judokit.android.examples}" +PREFS="shared_prefs/judokit_ds_certs.xml" +ARG="${1:-0}" + +die() { echo "error: $*" >&2; exit 1; } + +command -v adb >/dev/null || die "adb not found on PATH" +[ "$(adb get-state 2>/dev/null || true)" = "device" ] \ + || die "need exactly one device/emulator (see: adb devices; set ANDROID_SERIAL to pick one)" +adb shell pm path "$PKG" >/dev/null 2>&1 || die "package '$PKG' is not installed" + +# Read the prefs file as the app user. `run-as cat ` needs +# no device-side shell, so there is nothing for adb's arg re-splitting to break. +# run-as' cwd is the app's data dir, hence the relative path. +read_prefs() { adb exec-out run-as "$PKG" cat "$PREFS" 2>/dev/null; } + +# Write stdin back to the prefs file *as the app user*. The redirection has to +# happen inside the run-as'd shell, so the whole snippet is single-quoted at the +# device-shell level ("'...'") — otherwise adb hands `>` to the plain shell user +# and the write is denied. +write_prefs() { adb shell run-as "$PKG" sh -c "'cat > $PREFS'"; } + +# Pull one JSON scalar out of $CONTENT_NORM (quotes already normalised to "). +# $1 = key name. Echoes the value, or nothing if absent. +json_scalar() { + local key="$1" re + re="\"$key\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"" # "key":"string" + if [[ $CONTENT_NORM =~ $re ]]; then printf '%s\n' "${BASH_REMATCH[1]}"; return; fi + re="\"$key\"[[:space:]]*:[[:space:]]*(-?[0-9]+)" # "key":number + if [[ $CONTENT_NORM =~ $re ]]; then printf '%s\n' "${BASH_REMATCH[1]}"; fi +} + +show_meta() { + local k v + for k in etag lastModified fetchedAt maxAgeMs; do + v="$(json_scalar "$k")" + printf ' %-13s %s\n' "$k" "${v:-}" + done +} + +# --- load current cache ------------------------------------------------------ +CONTENT="$(read_prefs || true)" +CONTENT="${CONTENT//$'\r'/}" # strip stray CRs +if [ -z "$CONTENT" ]; then + if adb shell run-as "$PKG" true 2>&1 | grep -q "not debuggable"; then + die "package '$PKG' is not debuggable — run-as cannot reach its files. Install a debug build." + fi + die "DS-cert cache not found or empty ($PREFS). +Run a card payment in the app once so the SDK fetches and caches the certs, then re-run." +fi + +# Normalised copy for parsing: " / " -> " +CONTENT_NORM="${CONTENT//"/\"}" +CONTENT_NORM="${CONTENT_NORM//"/\"}" + +if [ "$ARG" = "show" ]; then + echo "current DS-cert cache ($PKG):" + show_meta + exit 0 +fi + +case "$ARG" in + ''|*[!0-9]*) die "fetchedAt must be an epoch-ms integer (or 'show'); got '$ARG'" ;; +esac + +CURRENT="$(json_scalar fetchedAt)" +[ -n "$CURRENT" ] || die "could not find 'fetchedAt' in $PREFS — cache JSON layout may have shifted" + +echo "before:" +show_meta + +# --- stop app so the edit is not masked by its in-memory prefs / clobbered -- +echo "> force-stopping $PKG" +adb shell am force-stop "$PKG" + +# --- rewrite fetchedAt (literal replace, every quote flavour) --------------- +NEW_CONTENT="$CONTENT" +for q in '"' '"' '"'; do + NEW_CONTENT="${NEW_CONTENT//${q}fetchedAt${q}:${CURRENT}/${q}fetchedAt${q}:${ARG}}" +done +[ "$NEW_CONTENT" != "$CONTENT" ] || die "rewrite produced no change — cache JSON layout may have shifted" + +# --- push it back ---------------------------------------------------------- +echo "> writing $PREFS" +printf '%s' "$NEW_CONTENT" | write_prefs + +# --- verify ---------------------------------------------------------------- +CONTENT="$(read_prefs || true)"; CONTENT="${CONTENT//$'\r'/}" +CONTENT_NORM="${CONTENT//"/\"}"; CONTENT_NORM="${CONTENT_NORM//"/\"}" +got="$(json_scalar fetchedAt)" +[ "$got" = "$ARG" ] || die "write-back verification failed (fetchedAt is '$got', expected '$ARG')" +echo "after:" +show_meta + +cat < + If-Modified-Since: +Unchanged CDN content -> HTTP 304 -> entries kept, fetchedAt bumped to ~now. + +Watch the '.../judokit/ds-certs' call in Chucker, then verify with: + scripts/simulate-ds-certs-304.sh show +EOF