diff --git a/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt b/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt index 70c463d5ce5f..0cd1f23b38d5 100644 --- a/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt +++ b/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt @@ -12,9 +12,11 @@ import javax.net.ssl.SSLPeerUnverifiedException /** * Certificate pinning for React Native's shared OkHttp client (Iteration 1 - NewDot). * - * On Android, `react-native-blob-util` (authenticated attachment/receipt downloads) and React Native - * networking (including `fetch()`) route through `OkHttpClientProvider.getOkHttpClient()`. Installing - * an [OkHttpClientProvider] factory with an OkHttp [CertificatePinner] here pins that traffic. + * On Android, `react-native-blob-util` (authenticated attachment/receipt downloads), Fresco, and + * other React Native networking consumers route through `OkHttpClientProvider.getOkHttpClient()`. + * Installing an [OkHttpClientProvider] factory with an OkHttp [CertificatePinner] here pins that + * traffic. JavaScript `fetch()` uses NitroFetch/Cronet and is pinned separately by the + * `react-native-nitro-fetch` patch. * * Additional networking channels are also monitored: * - **HttpURLConnection**: A wrapping [javax.net.ssl.HostnameVerifier] validates pins after the diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.kt b/android/app/src/main/java/com/expensify/chat/MainApplication.kt index 987a3d9b7d7c..89c47fdfa86d 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -14,6 +14,7 @@ import androidx.multidex.MultiDexApplication import com.expensify.chat.bootsplash.BootSplashPackage import com.expensify.chat.navbar.NavBarManagerPackage import com.expensify.chat.shortcutManagerModule.ShortcutManagerPackage +import com.margelo.nitro.nitrofetch.AutoPrefetcher import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost @@ -79,6 +80,12 @@ class MainApplication : MultiDexApplication(), ReactApplication { // Install certificate pinning for React Native's shared OkHttp client (covers fetch(), // react-native-blob-util, etc.). Must run before any networking starts. CertificatePinning.install() + // This is the entrypoint for prefetching with `react-native-nitro-fetch`. + try { + AutoPrefetcher.prefetchOnStart(this) + } catch (_: Throwable) { + System.err.println("Error initializing Nitro `AutoPrefetcher`") + } loadReactNative(this) diff --git a/config/certificatePinning/pins.json b/config/certificatePinning/pins.json index b456bb9870b9..dce0f95bfcdb 100644 --- a/config/certificatePinning/pins.json +++ b/config/certificatePinning/pins.json @@ -1,5 +1,5 @@ { - "_comment": "Canonical source of truth for SSL certificate pins (Iteration 1 - NewDot). Regenerate with scripts/generateCertificatePins.sh. Each domain pins the leaf SPKI hash (primary) and its issuing intermediate CA SPKI hash (durable backup that survives leaf rotation). Hashes are base64-encoded SHA-256 of the Subject Public Key Info. Native code (Android network_security_config_enforce.xml, Android OkHttpClientFactory, iOS TrustKit) MUST be kept in sync with this file - the generation script prints the values for each target.", + "_comment": "Canonical source of truth for SSL certificate pins (Iteration 1 - NewDot). Regenerate with scripts/generateCertificatePins.sh. Each domain pins the leaf SPKI hash (primary) and its issuing intermediate CA SPKI hash (durable backup that survives leaf rotation). Hashes are base64-encoded SHA-256 of the Subject Public Key Info. Native code (Android network_security_config_enforce.xml, Android OkHttpClientFactory, Android NitroFetch Cronet, iOS TrustKit) MUST be kept in sync with this file - the generation script prints the values for each target.", "_lastGenerated": "2026-06-03", "enforcePinning": false, "domains": { diff --git a/cspell.json b/cspell.json index be5ce9bf80b5..366c7b3a0ab0 100644 --- a/cspell.json +++ b/cspell.json @@ -1075,6 +1075,7 @@ "NitroFetch", "Prefetch", "Prefetcher", + "Autoprefetch", "knip", "lottiefiles", "jsitooling", diff --git a/index.js b/index.js index 978a5ffc7cfd..cf005180666d 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ * @format */ // import of polyfills should always be first +import './src/polyfills/NitroFetch'; import './src/polyfills/PromiseWithResolvers'; import './src/polyfills/requestIdleCallback'; import {AppRegistry} from 'react-native'; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cd9116d2c7f9..741d74122e11 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -410,7 +410,37 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - NitroModules (0.35.0): + - NitroFetch (1.5.4): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - NitroModules (0.36.3): - boost - DoubleConversion - fast_float @@ -4336,6 +4366,7 @@ DEPENDENCIES: - GzipSwift - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - lottie-react-native (from `../node_modules/lottie-react-native`) + - NitroFetch (from `../node_modules/react-native-nitro-fetch`) - NitroModules (from `../node_modules/react-native-nitro-modules`) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" - "pusher-websocket-react-native (from `../node_modules/@pusher/pusher-websocket-react-native`)" @@ -4566,6 +4597,8 @@ EXTERNAL SOURCES: :tag: hermes-v250829098.0.10 lottie-react-native: :path: "../node_modules/lottie-react-native" + NitroFetch: + :path: "../node_modules/react-native-nitro-fetch" NitroModules: :path: "../node_modules/react-native-nitro-modules" onfido-react-native-sdk: @@ -4817,7 +4850,7 @@ SPEC CHECKSUMS: DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXConstants: 1c400bb9969f4c9e5ab324553138b74ae47b9efe expensify-react-native-background-task: 03c640e1f5649692d058cba48c0a138f024a6dd3 - ExpensifyNitroUtils: 86109fe1ab88351ed63ffe11b760d537c70019d7 + ExpensifyNitroUtils: b7225649e39a31eab926dc8551c9c64e15b98d47 Expo: 844d649bc6228d8fa118f29984565b58745bc1d8 ExpoAsset: c2e7b5ba1fe75be683282d6264e38fd7cd8defbb ExpoAudio: 7774082d316ceecc2b26efeb10480bea2fa4d67e @@ -4864,7 +4897,8 @@ SPEC CHECKSUMS: MapboxCoreMaps: de1a4461d0ab5d65d907f06e8d82fdbe06bb3213 MapboxMaps: 22340ae0e0d47b2e4761019b1d6c569ab04e088d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: f8c2cc3025e4550aee15ff77c525622bf98e774a + NitroFetch: 4941ca1e41fc72ebdad33c50c85fee59a3dd6871 + NitroModules: 1961fff19baf1f4a5221dd2fea7d4de95941f9e4 NWWebSocket: b4741420f1976e1dff4da3edad00c401e4f1d769 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 onfido-react-native-sdk: bb8cfd9198e2e97978461d969497d18b37e45ca7 diff --git a/jest/setup.ts b/jest/setup.ts index c4d09ed07c61..76b88f58d8e6 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -313,6 +313,16 @@ jest.mock('react-native-nitro-sqlite', () => ({ open: jest.fn(), })); +jest.mock('react-native-nitro-fetch', () => ({ + __esModule: true, + fetch: (...args: Parameters) => globalThis.fetch(...args), + prefetchOnAppStart: jest.fn(() => Promise.resolve()), + registerTokenRefresh: jest.fn(), + clearTokenRefresh: jest.fn(), + removeFromAutoPrefetch: jest.fn(() => Promise.resolve()), + removeAllFromAutoprefetch: jest.fn(() => Promise.resolve()), +})); + jest.mock('@shopify/react-native-skia', () => ({ useFont: jest.fn(() => null), matchFont: jest.fn(() => null), diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp index f328c82635bd..ba0c3e861922 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp @@ -28,17 +28,17 @@ int initialize(JavaVM* vm) { } struct JHybridContactsModuleSpecImpl: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModule;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModule;"; static std::shared_ptr create() { - static auto constructorFn = javaClassStatic()->getConstructor(); + static const auto constructorFn = javaClassStatic()->getConstructor(); jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); return javaPart->getJHybridContactsModuleSpec(); } }; struct JHybridAppStartTimeModuleSpecImpl: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModule;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModule;"; static std::shared_ptr create() { - static auto constructorFn = javaClassStatic()->getConstructor(); + static const auto constructorFn = javaClassStatic()->getConstructor(); jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); return javaPart->getJHybridAppStartTimeModuleSpec(); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp index 2dccb42fa544..6725f3ee136d 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp @@ -21,11 +21,11 @@ namespace margelo::nitro::utils { using namespace facebook; /** - * The C++ JNI bridge between the C++ struct "Contact" and the the Kotlin data class "Contact". + * The C++ JNI bridge between the C++ struct "Contact" and the Kotlin data class "Contact". */ struct JContact final: public jni::JavaClass { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/Contact;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/Contact;"; public: /** @@ -48,26 +48,26 @@ namespace margelo::nitro::utils { return Contact( firstName != nullptr ? std::make_optional(firstName->toStdString()) : std::nullopt, lastName != nullptr ? std::make_optional(lastName->toStdString()) : std::nullopt, - phoneNumbers != nullptr ? std::make_optional([&]() { - size_t __size = phoneNumbers->size(); + phoneNumbers != nullptr ? std::make_optional([&](auto&& __input) { + size_t __size = __input->size(); std::vector __vector; __vector.reserve(__size); for (size_t __i = 0; __i < __size; __i++) { - auto __element = phoneNumbers->getElement(__i); + auto __element = __input->getElement(__i); __vector.push_back(__element->toCpp()); } return __vector; - }()) : std::nullopt, - emailAddresses != nullptr ? std::make_optional([&]() { - size_t __size = emailAddresses->size(); + }(phoneNumbers)) : std::nullopt, + emailAddresses != nullptr ? std::make_optional([&](auto&& __input) { + size_t __size = __input->size(); std::vector __vector; __vector.reserve(__size); for (size_t __i = 0; __i < __size; __i++) { - auto __element = emailAddresses->getElement(__i); + auto __element = __input->getElement(__i); __vector.push_back(__element->toCpp()); } return __vector; - }()) : std::nullopt, + }(emailAddresses)) : std::nullopt, imageData != nullptr ? std::make_optional(imageData->toStdString()) : std::nullopt ); } @@ -85,26 +85,26 @@ namespace margelo::nitro::utils { clazz, value.firstName.has_value() ? jni::make_jstring(value.firstName.value()) : nullptr, value.lastName.has_value() ? jni::make_jstring(value.lastName.value()) : nullptr, - value.phoneNumbers.has_value() ? [&]() { - size_t __size = value.phoneNumbers.value().size(); + value.phoneNumbers.has_value() ? [&](auto&& __input) { + size_t __size = __input.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { - const auto& __element = value.phoneNumbers.value()[__i]; + const auto& __element = __input[__i]; auto __elementJni = JStringHolder::fromCpp(__element); __array->setElement(__i, *__elementJni); } return __array; - }() : nullptr, - value.emailAddresses.has_value() ? [&]() { - size_t __size = value.emailAddresses.value().size(); + }(value.phoneNumbers.value()) : nullptr, + value.emailAddresses.has_value() ? [&](auto&& __input) { + size_t __size = __input.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { - const auto& __element = value.emailAddresses.value()[__i]; + const auto& __element = __input[__i]; auto __elementJni = JStringHolder::fromCpp(__element); __array->setElement(__i, *__elementJni); } return __array; - }() : nullptr, + }(value.emailAddresses.value()) : nullptr, value.imageData.has_value() ? jni::make_jstring(value.imageData.value()) : nullptr ); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp index 1ed3380932ce..ae0f1c356091 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp @@ -15,11 +15,11 @@ namespace margelo::nitro::utils { using namespace facebook; /** - * The C++ JNI bridge between the C++ enum "ContactFields" and the the Kotlin enum "ContactFields". + * The C++ JNI bridge between the C++ enum "ContactFields" and the Kotlin enum "ContactFields". */ struct JContactFields final: public jni::JavaClass { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/ContactFields;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/ContactFields;"; public: /** diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp index 91fc411edbd0..3a332f45b6f8 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp @@ -21,11 +21,11 @@ namespace margelo::nitro::utils { class JHybridAppStartTimeModuleSpec: public virtual HybridAppStartTimeModuleSpec, public virtual JHybridObject { public: struct JavaPart: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec;"; std::shared_ptr getJHybridAppStartTimeModuleSpec(); }; struct CxxPart: public jni::HybridClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec$CxxPart;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec$CxxPart;"; static jni::local_ref initHybrid(jni::alias_ref jThis); static void registerNatives(); using HybridBase::HybridBase; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp index 812c363b0d36..8b9cf258107e 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp @@ -61,30 +61,30 @@ namespace margelo::nitro::utils { // Methods std::shared_ptr>> JHybridContactsModuleSpec::getAll(const std::vector& keys) { static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref> /* keys */)>("getAll"); - auto __result = method(_javaPart, [&]() { - size_t __size = keys.size(); + auto __result = method(_javaPart, [&](auto&& __input) { + size_t __size = __input.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { - const auto& __element = keys[__i]; + const auto& __element = __input[__i]; auto __elementJni = JContactFields::fromCpp(__element); __array->setElement(__i, *__elementJni); } return __array; - }()); + }(keys)); return [&]() { auto __promise = Promise>::create(); __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { auto __result = jni::static_ref_cast>(__boxedResult); - __promise->resolve([&]() { - size_t __size = __result->size(); + __promise->resolve([&](auto&& __input) { + size_t __size = __input->size(); std::vector __vector; __vector.reserve(__size); for (size_t __i = 0; __i < __size; __i++) { - auto __element = __result->getElement(__i); + auto __element = __input->getElement(__i); __vector.push_back(__element->toCpp()); } return __vector; - }()); + }(__result)); }); __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { jni::JniException __jniError(__throwable); diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp index b140e3c9f376..36a9aee8047d 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp @@ -21,11 +21,11 @@ namespace margelo::nitro::utils { class JHybridContactsModuleSpec: public virtual HybridContactsModuleSpec, public virtual JHybridObject { public: struct JavaPart: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec;"; std::shared_ptr getJHybridContactsModuleSpec(); }; struct CxxPart: public jni::HybridClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec$CxxPart;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec$CxxPart;"; static jni::local_ref initHybrid(jni::alias_ref jThis); static void registerNatives(); using HybridBase::HybridBase; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp index 11b563d2b695..3344931a0385 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp @@ -17,11 +17,11 @@ namespace margelo::nitro::utils { using namespace facebook; /** - * The C++ JNI bridge between the C++ struct "StringHolder" and the the Kotlin data class "StringHolder". + * The C++ JNI bridge between the C++ struct "StringHolder" and the Kotlin data class "StringHolder". */ struct JStringHolder final: public jni::JavaClass { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/StringHolder;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/StringHolder;"; public: /** diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt index ca037de30643..4077492eb988 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt @@ -9,6 +9,7 @@ package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip +import java.util.Objects /** @@ -35,6 +36,26 @@ data class Contact( ) { /* primary constructor */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Contact) return false + return Objects.deepEquals(this.firstName, other.firstName) + && Objects.deepEquals(this.lastName, other.lastName) + && Objects.deepEquals(this.phoneNumbers, other.phoneNumbers) + && Objects.deepEquals(this.emailAddresses, other.emailAddresses) + && Objects.deepEquals(this.imageData, other.imageData) + } + + override fun hashCode(): Int { + return arrayOf( + firstName, + lastName, + phoneNumbers, + emailAddresses, + imageData + ).contentDeepHashCode() + } + companion object { /** * Constructor called from C++ diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt index accaebee2968..5cee30f9947f 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt @@ -9,6 +9,7 @@ package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip +import java.util.Objects /** @@ -23,6 +24,18 @@ data class StringHolder( ) { /* primary constructor */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StringHolder) return false + return Objects.deepEquals(this.value, other.value) + } + + override fun hashCode(): Int { + return arrayOf( + value + ).contentDeepHashCode() + } + companion object { /** * Constructor called from C++ diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb index 78c2c451cd92..c72938ed02d8 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb @@ -56,5 +56,7 @@ def add_nitrogen_files(spec) "SWIFT_OBJC_INTEROP_MODE" => "objcxx", # Enables stricter modular headers "DEFINES_MODULE" => "YES", + # Disable auto-generated ObjC header for Swift (Static linkage on Xcode 26.4 breaks here) + "SWIFT_INSTALL_OBJC_HEADER" => "NO", }) end diff --git a/package-lock.json b/package-lock.json index 1c2b16a51574..38e527a2e39d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,7 +121,8 @@ "react-native-key-command": "1.0.14", "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", - "react-native-nitro-modules": "0.35.0", + "react-native-nitro-fetch": "1.5.4", + "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -260,7 +261,7 @@ "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", - "nitrogen": "0.35.0", + "nitrogen": "0.36.3", "onchange": "^7.1.0", "openai": "^7.0.0", "oxc-transform": "0.136.0", @@ -17584,9 +17585,9 @@ } }, "node_modules/@ts-morph/common": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", - "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", "dev": true, "license": "MIT", "dependencies": { @@ -17606,26 +17607,26 @@ } }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -33557,17 +33558,17 @@ "license": "MIT" }, "node_modules/nitrogen": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.35.0.tgz", - "integrity": "sha512-K8/4h9bCQahi3qEheWZx5joLFsAW3QjK0dVSC3gNLlQhlSJN42UFmffAouOZXYjg9rBDpVlrVo+Hsja45swsJQ==", + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.36.3.tgz", + "integrity": "sha512-WOJLBBWyZPPPyBFYyScwGd1vfy6KvSTjlTug6eJy6RcTXDiMSiz7CjUmOPN6CIRN8o3PC3d/DLqDpo4/eGKbxA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^5.3.0", - "react-native-nitro-modules": "^0.35.0", - "ts-morph": "^27.0.0", + "react-native-nitro-modules": "^0.36.3", + "ts-morph": "^28.0.0", "yargs": "^18.0.0", - "zod": "^4.0.5" + "zod": "^4.4.3" }, "bin": { "nitrogen": "lib/index.js" @@ -33587,9 +33588,9 @@ } }, "node_modules/nitrogen/node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { @@ -35818,10 +35819,47 @@ } } }, + "node_modules/react-native-nitro-fetch": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/react-native-nitro-fetch/-/react-native-nitro-fetch-1.5.4.tgz", + "integrity": "sha512-RhiFeXkhSHhc1Qlo4qNG10a0LqKtfkg/W07+922+nXVkoWQMeZ/iY88dWU8bqi90LvR8wInmbovrorlTF8Qdxg==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "web-streams-polyfill": "^4.2.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "^0.36.1", + "react-native-worklets": ">=0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/react-native-nitro-fetch/node_modules/web-streams-polyfill": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.3.0.tgz", + "integrity": "sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw==", + "license": "MIT", + "workspaces": [ + "test/benchmark-test", + "test/rollup-test", + "test/webpack-test" + ], + "engines": { + "node": ">= 8" + } + }, "node_modules/react-native-nitro-modules": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.0.tgz", - "integrity": "sha512-Eho1yEcLbsteGpBFn2XZOp5FIptnEciWzuYBW49S0jo41Un2LeyesIO/MqYLY/c5o7D9Fw9th4pxGtV7OAb0+g==", + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.3.tgz", + "integrity": "sha512-8nVnqxN2ZJdKw8u6HnUXBStd/ERRMzvN93g+A48EhWWeYQVx4idKYRMuNGXXsPUNIWOreNKs2hky2PBfnCif/g==", "license": "MIT", "peerDependencies": { "react": "*", @@ -40089,13 +40127,13 @@ } }, "node_modules/ts-morph": { - "version": "27.0.2", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", - "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", "dev": true, "license": "MIT", "dependencies": { - "@ts-morph/common": "~0.28.1", + "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, diff --git a/package.json b/package.json index f24a0aa6beed..2ac24ae51003 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,8 @@ "react-native-key-command": "1.0.14", "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", - "react-native-nitro-modules": "0.35.0", + "react-native-nitro-fetch": "1.5.4", + "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -334,7 +335,7 @@ "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", - "nitrogen": "0.35.0", + "nitrogen": "0.36.3", "onchange": "^7.1.0", "openai": "^7.0.0", "oxc-transform": "0.136.0", diff --git a/patches/details.md b/patches/details.md new file mode 100644 index 000000000000..0792a2c47e60 --- /dev/null +++ b/patches/details.md @@ -0,0 +1,16 @@ +# `react-native-nitro-fetch` patches + +### [react-native-nitro-fetch+1.5.4.patch](react-native-nitro-fetch+1.5.4.patch) + +- Reason: + + ``` + NitroFetch bypasses the app's existing certificate-pinning clients on mobile. This patch applies + Expensify's public-key pins to both Android Cronet engines, supports monitor-only reporting via + a separate pinned Cronet probe, enforces pins on real traffic when enabled, validates standard requests, startup prefetches, token + refreshes, and streaming requests. + ``` + +- Upstream PR/issue: None. The certificate hosts and public-key pins are specific to Expensify's application configuration and are not appropriate defaults for react-native-nitro-fetch. +- E/App issue: https://github.com/Expensify/App/issues/90023 +- PR Introducing Patch: https://github.com/Expensify/App/pull/95518 diff --git a/patches/react-native-nitro-fetch+1.5.4.patch b/patches/react-native-nitro-fetch+1.5.4.patch new file mode 100644 index 000000000000..43a46ce0e5dd --- /dev/null +++ b/patches/react-native-nitro-fetch+1.5.4.patch @@ -0,0 +1,267 @@ +diff --git a/node_modules/react-native-nitro-fetch/android/build.gradle b/node_modules/react-native-nitro-fetch/android/build.gradle +index 45c0f78..2a3d84c 100644 +--- a/node_modules/react-native-nitro-fetch/android/build.gradle ++++ b/node_modules/react-native-nitro-fetch/android/build.gradle +@@ -139,6 +139,7 @@ dependencies { + // Provide org.chromium.net Java API for CronetEngine in Kotlin + // Cronet + api "org.chromium.net:cronet-embedded:${cronetVersion}" ++ compileOnly "io.sentry:sentry-android:8.33.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" + + } +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt +new file mode 100644 +index 0000000..a432432 +--- /dev/null ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt +@@ -0,0 +1,181 @@ ++package com.margelo.nitro.nitrofetch ++ ++import android.content.Context ++import android.net.Uri ++import io.sentry.Sentry ++import io.sentry.SentryLevel ++import org.chromium.net.CronetEngine ++import org.chromium.net.CronetException ++import org.chromium.net.NetworkException ++import org.chromium.net.UrlRequest ++import org.chromium.net.UrlResponseInfo ++import java.util.Date ++import java.util.concurrent.ConcurrentHashMap ++import java.util.concurrent.Executors ++import javax.net.ssl.SSLPeerUnverifiedException ++ ++/** ++ * Certificate pinning for the Cronet engines (Expensify Iteration 1 - NewDot). ++ * ++ * Patched into react-native-nitro-fetch via patch-package. Cronet uses its own BoringSSL trust stack ++ * and does NOT honor Android's network_security_config.xml, so public-key pins are applied directly ++ * to each CronetEngine.Builder in enforce mode. Monitor mode uses a separate pinned HEAD probe so ++ * the real request remains unblocked while pin mismatches are reported to Sentry. ++ * ++ * Each host pins the leaf SPKI hash (primary) plus its issuing intermediate CA SPKI hash (durable ++ * backup that survives leaf rotation). Keep in sync with config/certificatePinning/pins.json, ++ * android/app/src/main/res/xml/network_security_config.xml, CertificatePinning.kt and CertificatePinning.swift. ++ */ ++internal object ExpensifyCertificatePins { ++ // Keep in sync with config/certificatePinning/pins.json and the platform implementations. ++ private const val ENFORCE_PINNING = false ++ private const val PIN_MISMATCH_ERROR_CODE = -150 ++ private const val CERTIFICATE_PINNING_HOST_TAG = "certificate_pinning_host" ++ private const val CERTIFICATE_PINNING_MODE_TAG = "certificate_pinning_mode" ++ private const val CERTIFICATE_PINNING_CHANNEL_TAG = "certificate_pinning_channel" ++ ++ // 2100-01-01 UTC. Cronet requires a non-null pin expiration; rotation is managed via app updates. ++ private val PIN_EXPIRATION = Date(4102444800000L) ++ private val monitorExecutor = Executors.newSingleThreadExecutor { runnable -> ++ Thread(runnable, "NitroFetch-pin-monitor").apply { isDaemon = true } ++ } ++ private val monitoredHosts = ConcurrentHashMap.newKeySet() ++ ++ @Volatile ++ private var monitorEngine: CronetEngine? = null ++ ++ private fun decode(base64Hash: String): ByteArray = android.util.Base64.decode(base64Hash, android.util.Base64.DEFAULT) ++ ++ private val pinnedDomains: Map> = mapOf( ++ "www.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "secure.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "staging.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "staging-secure.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "new.expensify.com" to pins("G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "staging.new.expensify.com" to pins("G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "integrations.expensify.com" to pins("7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="), ++ "travel.expensify.com" to pins("Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "staging.travel.expensify.com" to pins("Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "d2k5nsl2zxldvw.cloudfront.net" to pins("P9HBoLji8YncXSnb0AnAm72fJO/vpmxZrsl4fvUBkxc=", "DxH4tt40L+eduF6szpY6TONlxhZhBd+pJ9wbHlQ2fuw="), ++ ) ++ ++ private fun pins(primary: String, backup: String): Set = setOf(decode(primary), decode(backup)) ++ ++ fun apply(builder: CronetEngine.Builder, context: Context) { ++ if (BuildConfig.DEBUG) { ++ return ++ } ++ ++ if (ENFORCE_PINNING) { ++ applyPins(builder) ++ return ++ } ++ ++ ensureMonitorEngine(context.applicationContext) ++ } ++ ++ fun monitor(url: String) { ++ if (BuildConfig.DEBUG || ENFORCE_PINNING) { ++ return ++ } ++ ++ val uri = Uri.parse(url) ++ if (!uri.scheme.equals("https", ignoreCase = true)) { ++ return ++ } ++ ++ val host = uri.host?.lowercase() ?: return ++ if (!pinnedDomains.containsKey(host) || !monitoredHosts.add(host)) { ++ return ++ } ++ ++ val engine = monitorEngine ++ if (engine == null) { ++ monitoredHosts.remove(host) ++ return ++ } ++ ++ val callback = object : UrlRequest.Callback() { ++ override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { ++ request.cancel() ++ } ++ ++ override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { ++ // A response proves the pinned TLS handshake succeeded; the body is irrelevant. ++ request.cancel() ++ } ++ ++ override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: java.nio.ByteBuffer) = Unit ++ ++ override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) = Unit ++ ++ override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { ++ if (isPinMismatch(error)) { ++ reportPinningFailure(host, error) ++ } else { ++ // Retry on a later request if this probe failed for an unrelated transient reason. ++ monitoredHosts.remove(host) ++ } ++ } ++ } ++ ++ try { ++ engine.newUrlRequestBuilder("https://$host/", callback, monitorExecutor) ++ .setHttpMethod("HEAD") ++ .disableCache() ++ .build() ++ .start() ++ } catch (_: Throwable) { ++ monitoredHosts.remove(host) ++ } ++ } ++ ++ fun reportIfPinFailure(url: String, error: CronetException) { ++ if (!ENFORCE_PINNING || !isPinMismatch(error)) { ++ return ++ } ++ ++ val host = Uri.parse(url).host?.lowercase() ?: return ++ if (pinnedDomains.containsKey(host)) { ++ reportPinningFailure(host, error) ++ } ++ } ++ ++ @Synchronized ++ private fun ensureMonitorEngine(context: Context) { ++ if (monitorEngine != null) { ++ return ++ } ++ ++ val builder = CronetEngine.Builder(context) ++ .enableHttp2(true) ++ .enableQuic(true) ++ .enableBrotli(true) ++ .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISABLED, 0) ++ .setUserAgent("NitroFetchPinMonitor/1.0") ++ applyPins(builder) ++ monitorEngine = builder.build() ++ } ++ ++ private fun applyPins(builder: CronetEngine.Builder) { ++ // Cronet bypasses configured pins for locally installed trust anchors by default. ++ builder.enablePublicKeyPinningBypassForLocalTrustAnchors(false) ++ pinnedDomains.forEach { (host, hashes) -> ++ builder.addPublicKeyPins(host, hashes, false, PIN_EXPIRATION) ++ } ++ } ++ ++ private fun isPinMismatch(error: CronetException): Boolean = ++ error is NetworkException && error.cronetInternalErrorCode == PIN_MISMATCH_ERROR_CODE ++ ++ private fun reportPinningFailure(host: String, cause: Throwable) { ++ val error = SSLPeerUnverifiedException("Certificate pinning validation failed for $host") ++ error.initCause(cause) ++ Sentry.captureException(error) { scope -> ++ scope.level = SentryLevel.WARNING ++ scope.setTag(CERTIFICATE_PINNING_HOST_TAG, host) ++ scope.setTag(CERTIFICATE_PINNING_MODE_TAG, if (ENFORCE_PINNING) "enforce" else "monitor") ++ scope.setTag(CERTIFICATE_PINNING_CHANNEL_TAG, "cronet") ++ } ++ } ++} +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt +index 49443e8..1d19f04 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt +@@ -64,6 +64,9 @@ class HybridNitroCronet : HybridNitroCronetSpec() { + (50 * 1024 * 1024).toLong() + ) + ++ // Certificate pinning (Expensify) - Cronet does not honor network_security_config.xml. ++ ExpensifyCertificatePins.apply(builder, app) ++ + val engine = builder.build() + engineRef = engine + return engine +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt +index 1e5e0ff..55a83a6 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt +@@ -59,6 +59,9 @@ class HybridNitroFetch : HybridNitroFetchSpec() { + // Prove DNS issues by mapping a host (TESTING ONLY, remove in prod): + // builder.setExperimentalOptions("""{"HostResolverRules":{"host_resolver_rules":"MAP httpbin.org 54.167.17.38"}}""") + ++ // Certificate pinning (Expensify) - Cronet does not honor network_security_config.xml. ++ ExpensifyCertificatePins.apply(builder, app) ++ + val engine = builder.build() + NitroLogger.i("NitroFetch", "CronetEngine initialized. Provider=${nativeProvider?.name ?: "Default"} Cache=${cacheDir.absolutePath}") + engineRef = engine +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt +index 283bd37..24f7c22 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt +@@ -107,6 +107,7 @@ class HybridNitroFetchClient(private val engine: CronetEngine, private val execu + onFail: (Throwable) -> Unit + ): UrlRequest { + val url = req.url ++ ExpensifyCertificatePins.monitor(url) + val shouldFollowRedirects = req.followRedirects ?: true + val omitCredentials = req.credentials == NitroRequestCredentials.OMIT + val traceLabel = if (BuildConfig.NITRO_FETCH_TRACING) { +@@ -250,6 +251,7 @@ class HybridNitroFetchClient(private val engine: CronetEngine, private val execu + } + + override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { ++ ExpensifyCertificatePins.reportIfPinFailure(url, error) + if (BuildConfig.NITRO_FETCH_TRACING) { + Trace.endAsyncSection(traceLabel, traceCookie) + } +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt +index 48f2dc2..07806ba 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt +@@ -120,6 +120,7 @@ class HybridUrlRequestBuilder( + info: CronetUrlResponseInfo?, + error: CronetNativeException + ) { ++ ExpensifyCertificatePins.reportIfPinFailure(url, error) + if (devToolsEnabled) { + DevToolsReporter.reportRequestFailed(devToolsRequestId, false) + } +@@ -229,6 +230,7 @@ class HybridUrlRequestBuilder( + } + + override fun build(): HybridUrlRequestSpec { ++ ExpensifyCertificatePins.monitor(url) + val cronetRequest = builder.build() + if (devToolsEnabled) { + DevToolsReporter.reportRequestStart( diff --git a/scripts/generateCertificatePins.sh b/scripts/generateCertificatePins.sh index b006464da7df..ecb44caddfdd 100755 --- a/scripts/generateCertificatePins.sh +++ b/scripts/generateCertificatePins.sh @@ -21,6 +21,7 @@ # - android/app/src/main/res/xml/network_security_config_enforce.xml # - android/app/src/main/java/com/expensify/chat/CertificatePinning.kt # - ios/CertificatePinning.swift +# - patches/react-native-nitro-fetch+1.5.4.patch # - Mobile-Expensify/Android/res/xml/network_security_config_enforce.xml # - Mobile-Expensify/Android/src/yapl/android/http/ExpensifyCertificatePinner.java # - Mobile-Expensify/iOS/Expensify/ExpensifyAppDelegate.m (TrustKit kTSKPublicKeyHashes) diff --git a/server/stubs/react-native-nitro-fetch.ts b/server/stubs/react-native-nitro-fetch.ts index 6824519e84a0..0378ddecff1a 100644 --- a/server/stubs/react-native-nitro-fetch.ts +++ b/server/stubs/react-native-nitro-fetch.ts @@ -2,6 +2,12 @@ function clearTokenRefresh() {} function prefetchOnAppStart() {} -export {clearTokenRefresh, prefetchOnAppStart}; +function registerTokenRefresh() {} + +function removeFromAutoPrefetch() {} + +function removeAllFromAutoprefetch() {} + +export {clearTokenRefresh, prefetchOnAppStart, registerTokenRefresh, removeFromAutoPrefetch, removeAllFromAutoprefetch}; export default {}; diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 24e2033b9a99..4c5182b8c93f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2480,6 +2480,9 @@ const CONST = { GATEWAY_TIMEOUT: 504, UNKNOWN_ERROR: 520, }, + HTTP_HEADER_NAMES: { + AUTH_TOKEN: 'authToken', + }, ERROR: { XHR_FAILED: 'xhrFailed', THROTTLED: 'throttled', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e57c87cf6359..6db64f2167ce 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -10,6 +10,8 @@ import type * as Parameters from './parameters'; import type SignInUserParams from './parameters/SignInUserParams'; import type UpdateBeneficialOwnersForBankAccountParams from './parameters/UpdateBeneficialOwnersForBankAccountParams'; +const AUTHENTICATION_COMMAND = 'Authenticate' as const; + type ApiRequestType = ValueOf; const WRITE_COMMANDS = { @@ -1639,7 +1641,7 @@ type SideEffectRequestCommandParameters = { type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; -export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS}; +export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, AUTHENTICATION_COMMAND}; type ApiCommand = WriteCommand | ReadCommand | SideEffectRequestCommand; type CommandOfType = TRequestType extends typeof CONST.API_REQUEST_TYPE.WRITE diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index bc4c944a31a6..2dd799f5c9c6 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -5,6 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {RequestType} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; +import type {fetch as nitroFetch} from 'react-native-nitro-fetch'; import type {OnyxKey} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -16,6 +17,8 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from './API import {getCommandURL} from './ApiUtils'; import HttpsError from './Errors/HttpsError'; import {setLoadTestParameters} from './Network/LoadTestState'; +import preparePrefetchRequest from './Prefetch/preparePrefetchRequest'; +import registerPrefetchOnAppStart from './Prefetch/registerPrefetchOnAppStart'; import prepareRequestPayload from './prepareRequestPayload'; import markAppStartupNetworkRequestEnd from './telemetry/markAppStartupNetworkRequestEnd'; @@ -78,17 +81,24 @@ function processHTTPRequest( const command = url.match(APICommandRegex)?.[1]; - return fetch(url, { + const {prefetchKey, prefetchHeaders} = preparePrefetchRequest(command); + + const fetchParams: NonNullable[1]> = { // We hook requests to the same Controller signal, so we can cancel them all at once signal: abortSignal, method, body, + headers: prefetchHeaders, // On Web fetch already defaults to 'omit' for credentials, but it seems that this is not the case for the ReactNative implementation // so to avoid sending cookies with the request we set it to 'omit' explicitly // this avoids us sending specially the expensifyWeb cookie, which makes a CSRF token required // more on that here: https://stackoverflowteams.com/c/expensify/questions/93 credentials: 'omit', - }) + }; + + registerPrefetchOnAppStart({prefetchKey, fetchParams, command, url}); + + return fetch(url, fetchParams) .then((response) => { if (response.headers) { setLoadTestParameters(response.headers.get('X-Load-Test')); diff --git a/src/libs/Network/NetworkStore.ts b/src/libs/Network/NetworkStore.ts index db04596fc8ee..1586dd74957b 100644 --- a/src/libs/Network/NetworkStore.ts +++ b/src/libs/Network/NetworkStore.ts @@ -13,6 +13,7 @@ let credentials: Credentials | null | undefined; let lastShortAuthToken: string | null | undefined; let authToken: string | null | undefined; let authTokenType: ValueOf | null; +let accountID: number | null | undefined; let authenticating = false; let resolveIsReadyPromise: (args?: unknown[]) => void; @@ -45,6 +46,7 @@ Onyx.connectWithoutView({ callback: (val) => { authToken = val?.authToken ?? null; authTokenType = val?.authTokenType ?? null; + accountID = val?.accountID ?? null; checkRequiredData(); }, }); @@ -67,6 +69,10 @@ function getAuthToken(): string | null | undefined { return authToken; } +function getAccountID(): number | null | undefined { + return accountID; +} + function getLastShortAuthToken(): string | null | undefined { return lastShortAuthToken; } @@ -97,6 +103,7 @@ function setIsAuthenticating(val: boolean) { export { getAuthToken, + getAccountID, setAuthToken, getCurrentUserEmail, hasReadRequiredDataFromStorage, diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts index 4e397d44d742..a6f7de574105 100644 --- a/src/libs/Network/enhanceParameters.ts +++ b/src/libs/Network/enhanceParameters.ts @@ -1,3 +1,4 @@ +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; import * as Environment from '@libs/Environment/Environment'; import getPlatform from '@libs/getPlatform'; @@ -50,34 +51,38 @@ Onyx.connectWithoutView({ * Does this command require an authToken? */ function isAuthTokenRequired(command: string): boolean { - return !['Log', 'Authenticate', 'BeginSignIn', 'SetPassword'].includes(command); + return !['Log', AUTHENTICATION_COMMAND, 'BeginSignIn', 'SetPassword'].includes(command); +} + +/** + * Returns request metadata shared by every API call (including Authenticate). + */ +function getBaseRequestParameters(email?: unknown) { + return { + referer: CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER, + platform: getPlatform(), + // This application does not save its authToken in cookies like the classic Expensify app. + // Setting api_setCookie to false will ensure that the Expensify API doesn't set any cookies + // and prevents interfering with the cookie authToken that Expensify classic uses. + // eslint-disable-next-line @typescript-eslint/naming-convention + api_setCookie: false, + // Include current user's email in every request and the server logs + email: email ?? getCurrentUserEmail(), + isFromDevEnv: Environment.isDevelopment(), + appversion: pkg.version, + }; } /** * Adds default values to our request data */ export default function enhanceParameters(command: string, parameters: Record): Record { - const finalParameters = {...parameters}; + const finalParameters: Record = {...parameters, ...getBaseRequestParameters(parameters.email)}; if (isAuthTokenRequired(command) && !parameters.authToken) { finalParameters.authToken = getAuthToken() ?? null; } - finalParameters.referer = CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER; - - // In addition to the referer (ecash), we pass the platform to help differentiate what device type - // is sending the request. - finalParameters.platform = getPlatform(); - - // This application does not save its authToken in cookies like the classic Expensify app. - // Setting api_setCookie to false will ensure that the Expensify API doesn't set any cookies - // and prevents interfering with the cookie authToken that Expensify classic uses. - finalParameters.api_setCookie = false; - - // Include current user's email in every request and the server logs - finalParameters.email = parameters.email ?? getCurrentUserEmail(); - finalParameters.isFromDevEnv = Environment.isDevelopment(); - finalParameters.appversion = pkg.version; finalParameters.clientUpdateID = lastUpdateIDAppliedToClient; if (delegate) { finalParameters.delegate = delegate; @@ -88,3 +93,5 @@ export default function enhanceParameters(command: string, parameters: Record([WRITE_COMMANDS.RECONNECT_APP]); + +export default PrefetchQueries; diff --git a/src/libs/Prefetch/PrefetchQueries/index.ts b/src/libs/Prefetch/PrefetchQueries/index.ts new file mode 100644 index 000000000000..da26d587fbc3 --- /dev/null +++ b/src/libs/Prefetch/PrefetchQueries/index.ts @@ -0,0 +1,8 @@ +/** + * Native-only prefetching via `react-native-nitro-fetch` is unavailable on web, so no commands are prefetched. + * Keeping this empty also prevents the `prefetchKey` request header from being added on web, which would otherwise + * trigger a CORS preflight on cross-origin requests. + */ +const PrefetchQueries = new Set(); + +export default PrefetchQueries; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts new file mode 100644 index 000000000000..f50a2b195374 --- /dev/null +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts @@ -0,0 +1,18 @@ +/** + * Clears the prefetch on app start. + */ +import Log from '@libs/Log'; + +import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch'; + +import type ClearPrefetchOnAppStart from './types'; + +const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = async () => { + clearTokenRefresh('fetch'); + + await removeAllFromAutoprefetch().catch((error) => { + Log.warn('[HttpUtils] removeAllFromAutoprefetch failed', {error}); + }); +}; + +export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts new file mode 100644 index 000000000000..541f01529a1a --- /dev/null +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts @@ -0,0 +1,5 @@ +import type ClearPrefetchOnAppStart from './types'; + +const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = () => Promise.resolve(); + +export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts new file mode 100644 index 000000000000..1c13e912db4e --- /dev/null +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts @@ -0,0 +1,3 @@ +type ClearPrefetchOnAppStart = () => Promise; + +export default ClearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/preparePrefetchRequest/index.ts b/src/libs/Prefetch/preparePrefetchRequest/index.ts new file mode 100644 index 000000000000..6963153ff1ac --- /dev/null +++ b/src/libs/Prefetch/preparePrefetchRequest/index.ts @@ -0,0 +1,28 @@ +import {getAccountID} from '@libs/Network/NetworkStore'; +/** + * Prepares a prefetch request for a given command. + */ +import PrefetchQueries from '@libs/Prefetch/PrefetchQueries'; + +import type PreparePrefetchRequest from './types'; + +const preparePrefetchRequest: PreparePrefetchRequest = (command) => { + // Prefetch the request on next app start if the prefetch key is present in the headers + // This allows to fetch the request natively before the JS bundle is loaded. Once the request with this prefetch key is made, it will already be cached and served from the cache. + // Native cache entries outlive the JS session, so never share a command-only key across accounts. + const accountID = getAccountID(); + const prefetchKey = command && PrefetchQueries.has(command) && accountID !== null && accountID !== undefined ? `${command}:${accountID}` : undefined; + + const prefetchHeaders = prefetchKey + ? { + prefetchKey, + } + : undefined; + + return { + prefetchKey, + prefetchHeaders, + }; +}; + +export default preparePrefetchRequest; diff --git a/src/libs/Prefetch/preparePrefetchRequest/index.web.ts b/src/libs/Prefetch/preparePrefetchRequest/index.web.ts new file mode 100644 index 000000000000..07d3f41f8d63 --- /dev/null +++ b/src/libs/Prefetch/preparePrefetchRequest/index.web.ts @@ -0,0 +1,7 @@ +import type PreparePrefetchRequest from './types'; + +const NOOP: PreparePrefetchRequest = () => ({}); + +const preparePrefetchRequest = NOOP; + +export default preparePrefetchRequest; diff --git a/src/libs/Prefetch/preparePrefetchRequest/types.ts b/src/libs/Prefetch/preparePrefetchRequest/types.ts new file mode 100644 index 000000000000..fded935a40ca --- /dev/null +++ b/src/libs/Prefetch/preparePrefetchRequest/types.ts @@ -0,0 +1,8 @@ +type PreparePrefetchRequestResult = { + prefetchKey?: string; + prefetchHeaders?: Record; +}; + +type PreparePrefetchRequest = (command: string | undefined) => PreparePrefetchRequestResult; + +export default PreparePrefetchRequest; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts new file mode 100644 index 000000000000..8a18ea9ef288 --- /dev/null +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -0,0 +1,88 @@ +/** + * Registers a native startup prefetch (and its Authenticate token-refresh config) with + * `react-native-nitro-fetch` so whitelisted API requests can be fetched before the JS bundle loads. + */ +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; +import {getCommandURL} from '@libs/ApiUtils'; +import Log from '@libs/Log'; +import {getBaseRequestParameters} from '@libs/Network/enhanceParameters'; +import {getCredentials} from '@libs/Network/NetworkStore'; +import {getPartnerCredentials} from '@libs/SessionUtils'; + +import CONST from '@src/CONST'; +import type Credentials from '@src/types/onyx/Credentials'; + +import {clearTokenRefresh, prefetchOnAppStart, registerTokenRefresh} from 'react-native-nitro-fetch'; + +import type RegisterPrefetchOnAppStart from './types'; + +const NITRO_FETCH_TARGET = 'fetch'; +const CONTENT_TYPE_HEADER = 'Content-Type'; + +function buildAuthenticateBody(credentials: Credentials): string { + // NitroFetch token refresh runs outside the JS request pipeline, so we must serialize the + // Authenticate body here instead of going through Request.post() and enhanceParameters(). + const {partnerName, partnerPassword} = getPartnerCredentials(credentials.autoGeneratedLogin); + + // The `appversion` parameter is omitted intentionally here. + // This is because it can cause issues during app updates and + // it is not needed by the "Authenticate" command for token refresh. + // eslint-disable-next-line @typescript-eslint/naming-convention + const {referer, platform, api_setCookie, isFromDevEnv} = getBaseRequestParameters(); + + const body = new URLSearchParams(); + body.append('useExpensifyLogin', 'false'); + body.append('partnerName', partnerName); + body.append('partnerPassword', partnerPassword); + body.append('partnerUserID', credentials.autoGeneratedLogin ?? ''); + body.append('partnerUserSecret', credentials.autoGeneratedPassword ?? ''); + body.append('shouldRetry', 'false'); + body.append('forceNetworkRequest', 'true'); + body.append('email', credentials.login ?? ''); + body.append('referer', referer); + body.append('platform', platform); + body.append('api_setCookie', String(api_setCookie)); + body.append('isFromDevEnv', String(isFromDevEnv)); + + return body.toString(); +} + +function registerPrefetchTokenRefresh(): void { + const credentials = getCredentials(); + + clearTokenRefresh(NITRO_FETCH_TARGET); + + if (!credentials?.autoGeneratedLogin || !credentials.autoGeneratedPassword) { + return; + } + + const authenticateURL = `${getCommandURL({command: AUTHENTICATION_COMMAND})}`; + + registerTokenRefresh({ + target: NITRO_FETCH_TARGET, + url: authenticateURL, + method: 'POST', + headers: { + [CONTENT_TYPE_HEADER]: 'application/x-www-form-urlencoded', + }, + body: buildAuthenticateBody(credentials), + responseType: 'json', + formDataMappings: [{jsonPath: CONST.HTTP_HEADER_NAMES.AUTH_TOKEN, field: CONST.HTTP_HEADER_NAMES.AUTH_TOKEN}], + onFailure: 'useStoredHeaders', + }); + + Log.info('[NitroFetchTokenRefresh] Registered token refresh for startup prefetch', false); +} + +const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = ({prefetchKey, fetchParams, command, url}) => { + if (!prefetchKey) { + return; + } + + registerPrefetchTokenRefresh(); + prefetchOnAppStart(url, fetchParams).catch((error) => { + Log.warn(`[NitroFetch] prefetchOnAppStart failed for ${command}`, {error, fetchParams, url}); + }); +}; + +export default registerPrefetchOnAppStart; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts new file mode 100644 index 000000000000..273beecee833 --- /dev/null +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts @@ -0,0 +1,7 @@ +import type RegisterPrefetchOnAppStart from './types'; + +const NOOP: RegisterPrefetchOnAppStart = () => {}; + +const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = NOOP; + +export default registerPrefetchOnAppStart; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/types.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/types.ts new file mode 100644 index 000000000000..1e503b27115f --- /dev/null +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/types.ts @@ -0,0 +1,10 @@ +type RegisterPrefetchOnAppStartParams = { + prefetchKey: string | undefined; + command: string | undefined; + url: string; + fetchParams: RequestInit; +}; + +type RegisterPrefetchOnAppStart = (params: RegisterPrefetchOnAppStartParams) => void; + +export default RegisterPrefetchOnAppStart; diff --git a/src/libs/Reauthentication.ts b/src/libs/Reauthentication.ts index 64f53c53bb66..86deedf89156 100644 --- a/src/libs/Reauthentication.ts +++ b/src/libs/Reauthentication.ts @@ -1,4 +1,3 @@ -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Account} from '@src/types/onyx'; @@ -15,13 +14,14 @@ import {isConnectedAsDelegate, restoreDelegateSession} from './actions/Delegate' import clearShortLivedAuthState from './actions/Session/clearShortLivedAuthState'; import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens'; import redirectToSignIn from './actions/SignInRedirect'; +import {AUTHENTICATION_COMMAND} from './API/types'; import HttpsError from './Errors/HttpsError'; import {getAuthenticateErrorMessage, getErrorMessage} from './ErrorUtils'; import Log from './Log'; import {post} from './Network'; import {getCredentials, hasReadRequiredDataFromStorage, setAuthToken, setIsAuthenticating} from './Network/NetworkStore'; import requireParameters from './requireParameters'; -import {checkIfShouldUseNewPartnerName} from './SessionUtils'; +import {checkIfShouldUseNewPartnerName, getPartnerCredentials} from './SessionUtils'; import trackAuthenticationError from './telemetry/trackAuthenticationError'; type Parameters = { @@ -106,7 +106,7 @@ Onyx.connectWithoutView({ }); function Authenticate(parameters: Parameters): Promise | void> { - const commandName = 'Authenticate'; + const commandName = AUTHENTICATION_COMMAND; try { requireParameters(['partnerName', 'partnerPassword', 'partnerUserID', 'partnerUserSecret'], parameters, commandName); @@ -115,7 +115,7 @@ function Authenticate(parameters: Parameters): Promise { delegatedAccess: account?.delegatedAccess, }); const credentials = isDelegate ? stashedCredentials : getCredentials(); - const shouldUseNewPartnerName = checkIfShouldUseNewPartnerName(credentials?.autoGeneratedLogin); - - const partnerName = shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_NAME : CONFIG.EXPENSIFY.LEGACY_PARTNER_NAME; - const partnerPassword = shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_PASSWORD : CONFIG.EXPENSIFY.LEGACY_PARTNER_PASSWORD; + const {partnerName, partnerPassword} = getPartnerCredentials(credentials?.autoGeneratedLogin); // Supportal sessions authenticate with a short-lived support auth token and must never be sent through the // customer's SAML flow. Skipping the SAML redirect lets a support session fall through to the normal @@ -243,7 +240,7 @@ function reauthenticate(command = ''): Promise { return false; } - Log.info(`[Reauthenticate] Re-authenticating with ${shouldUseNewPartnerName ? 'new' : 'old'} partner name`); + Log.info(`[Reauthenticate] Re-authenticating with ${checkIfShouldUseNewPartnerName(credentials?.autoGeneratedLogin) ? 'new' : 'old'} partner name`); Log.hmmm('[Reauthenticate] Starting authentication process', { command, diff --git a/src/libs/Request.ts b/src/libs/Request.ts index 661740eef662..5268379d40c8 100644 --- a/src/libs/Request.ts +++ b/src/libs/Request.ts @@ -20,20 +20,24 @@ function makeXHR(request: Request): Promise(request: Request, isFromSequentialQueue = false): Promise | void> { - return middlewares - .reduce((last, middleware) => middleware(last, request, isFromSequentialQueue), makeXHR(request)) - .catch((reason: unknown) => { - // Real Errors are already normalized/classified by the Logging middleware; pass them through untouched. - if (reason instanceof Error) { - throw reason; - } - // A non-Error rejection (e.g. a bare `null` bubbling up from an outer data middleware above - // Logging) would otherwise surface as a stack-less, context-free onunhandledrejection (APP-5J). - // Wrap it so the next occurrence on any command carries command context and a stack. - const normalizedError = new Error(`[API] ${request.command} rejected: ${String(reason)}`); - Log.alert('[API] non-Error rejection surfaced from the request pipeline', {command: request.command, reason: String(reason)}); - throw normalizedError; - }); + let result = makeXHR(request); + + for (const middleware of middlewares) { + result = middleware(result, request, isFromSequentialQueue); + } + + return result.catch((reason: unknown) => { + // Real Errors are already normalized/classified by the Logging middleware; pass them through untouched. + if (reason instanceof Error) { + throw reason; + } + // A non-Error rejection (e.g. a bare `null` bubbling up from an outer data middleware above + // Logging) would otherwise surface as a stack-less, context-free onunhandledrejection (APP-5J). + // Wrap it so the next occurrence on any command carries command context and a stack. + const normalizedError = new Error(`[API] ${request.command} rejected: ${String(reason)}`); + Log.alert('[API] non-Error rejection surfaced from the request pipeline', {command: request.command, reason: String(reason)}); + throw normalizedError; + }); } function addMiddleware(middleware: Middleware) { diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index 4700bae5228b..8f362ab48aed 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -110,6 +110,15 @@ function checkIfShouldUseNewPartnerName(partnerUserID?: string): boolean { return false; } +function getPartnerCredentials(autoGeneratedLogin?: string) { + const useNewPartnerName = checkIfShouldUseNewPartnerName(autoGeneratedLogin); + + return { + partnerName: useNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_NAME : CONFIG.EXPENSIFY.LEGACY_PARTNER_NAME, + partnerPassword: useNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_PASSWORD : CONFIG.EXPENSIFY.LEGACY_PARTNER_PASSWORD, + }; +} + const AGENT_EMAIL_REGEX = /^agent_\d+@expensify\.ai$/i; function isAgentEmail(email?: string): boolean { @@ -124,4 +133,13 @@ function useIsAgentAccount(): boolean { return isAgentEmail(sessionEmail); } -export {isLoggingInAsNewUser, didUserLogInDuringSession, resetDidUserLogInDuringSession, checkIfShouldUseNewPartnerName, isLoggingInAsDelegate, isAgentEmail, useIsAgentAccount}; +export { + isLoggingInAsNewUser, + didUserLogInDuringSession, + resetDidUserLogInDuringSession, + checkIfShouldUseNewPartnerName, + getPartnerCredentials, + isLoggingInAsDelegate, + isAgentEmail, + useIsAgentAccount, +}; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7b73c75c0340..694e01c1148f 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -34,12 +34,13 @@ import * as MainQueue from '@libs/Network/MainQueue'; import * as NetworkStore from '@libs/Network/NetworkStore'; import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import Pusher from '@libs/Pusher'; import reauthenticate from '@libs/Reauthentication'; import {getReportIDFromLink} from '@libs/ReportUtils'; import {runSessionCleanupCallbacks} from '@libs/SessionCleanup'; import * as SessionUtils from '@libs/SessionUtils'; -import {checkIfShouldUseNewPartnerName, resetDidUserLogInDuringSession} from '@libs/SessionUtils'; +import {getPartnerCredentials, resetDidUserLogInDuringSession} from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; import {logReceiptQueueSnapshot} from '@libs/telemetry/ReceiptObservability'; import Timers from '@libs/Timers'; @@ -260,17 +261,21 @@ function signInWithSupportAuthToken(authToken: string) { /** * Calls the LOG_OUT API to invalidate the session on the server. */ -function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise> { +async function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise> { Log.info('Flushing logs before signing out', true, {}, true); logReceiptQueueSnapshot('signOut'); - const shouldUseNewPartnerName = checkIfShouldUseNewPartnerName(params.autoGeneratedLogin); + const {partnerName, partnerPassword} = getPartnerCredentials(params.autoGeneratedLogin); + + // When the user signs out, clear native startup prefetch state so the next cold start cannot replay + // a queued ReconnectApp request built with the previous session's auth token. + await clearPrefetchOnAppStart(); const logOutParams: LogOutParams = { // Send current authToken because we will immediately clear it once triggering this command authToken: params.authToken ?? null, partnerUserID: params.autoGeneratedLogin ?? '', - partnerName: shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_NAME : CONFIG.EXPENSIFY.LEGACY_PARTNER_NAME, - partnerPassword: shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_PASSWORD : CONFIG.EXPENSIFY.LEGACY_PARTNER_PASSWORD, + partnerName, + partnerPassword, shouldRetry: false, skipReauthentication: true, }; diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts index e76cbe77ce23..aff760caf7f0 100644 --- a/src/libs/actions/Session/updateSessionAuthTokens.ts +++ b/src/libs/actions/Session/updateSessionAuthTokens.ts @@ -1,7 +1,16 @@ +import Log from '@libs/Log'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; + import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { + // Startup prefetches are persisted natively across launches. Drop any queue/token-refresh + // config tied to the previous auth token before saving the replacement. + clearPrefetchOnAppStart().catch((error) => { + Log.warn('[NitroFetch] clearPrefetchOnAppStart failed', {error}); + }); + return Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, creationDate: new Date().getTime()}); } diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 874c2a083355..8fb9b9f79544 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -1,6 +1,7 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import CONFIG from '@src/CONFIG'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -92,7 +93,10 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true}); } - return Onyx.clear(keysToPreserve).then(() => { + return Onyx.clear(keysToPreserve).then(async () => { + // Requests may be processed while sign-out is in progress. Clear again after credentials have been removed so none of those requests remain queued for the next startup. + await clearPrefetchOnAppStart(); + if (CONFIG.IS_HYBRID_APP) { resetSignInFlow(); HybridAppModule.signOutFromOldDot(); diff --git a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts index 8b2a0e8b9d81..35748063bd47 100644 --- a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts +++ b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts @@ -1,4 +1,5 @@ import DateUtils from '@libs/DateUtils'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -18,12 +19,20 @@ import Onyx from 'react-native-onyx'; * IS_LOADING_APP=true for delegate transitions). Seeded keys are appended to the * preserve list automatically so they survive the clear. */ -function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSeeds?: OnyxMultiSetInput): Promise { +async function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSeeds?: OnyxMultiSetInput): Promise { + // Any Onyx reset can preserve or replace SESSION/CREDENTIALS while dropping account-scoped data. + // Clear native startup prefetches so a cold start cannot replay requests from the previous identity. + await clearPrefetchOnAppStart(); + const seeds: OnyxMultiSetInput = { ...extraSeeds, [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: DateUtils.getDBTime(), }; - return Onyx.multiSet(seeds).then(() => Onyx.clear([...keysToPreserve, ...(Object.keys(seeds) as OnyxKey[])])); + await Onyx.multiSet(seeds); + await Onyx.clear([...keysToPreserve, ...(Object.keys(seeds) as OnyxKey[])]); + + // A request can be processed between the initial cleanup and the Onyx reset. Clear the queue again after the old credentials are gone. + await clearPrefetchOnAppStart(); } export default clearOnyxAndSeedFullReconnect; diff --git a/src/libs/telemetry/trackAuthenticationError.ts b/src/libs/telemetry/trackAuthenticationError.ts index ec3e09ff1c24..0ec4a1da8ee5 100644 --- a/src/libs/telemetry/trackAuthenticationError.ts +++ b/src/libs/telemetry/trackAuthenticationError.ts @@ -1,8 +1,10 @@ +import type {AUTHENTICATION_COMMAND} from '@libs/API/types'; + import CONST from '@src/CONST'; import * as Sentry from '@sentry/react-native'; -type AuthenticationFunction = 'Authenticate' | 'reauthenticate'; +type AuthenticationFunction = typeof AUTHENTICATION_COMMAND | 'reauthenticate'; type AuthenticationErrorType = 'missing_params' | 'network_retry' | 'auth_failure' | 'unexpected_error'; type AuthenticationErrorContext = { diff --git a/src/polyfills/NitroFetch.ts b/src/polyfills/NitroFetch.ts new file mode 100644 index 000000000000..62c3c4c30f3a --- /dev/null +++ b/src/polyfills/NitroFetch.ts @@ -0,0 +1,10 @@ +/** + * Use Nitro's fetch implementation for every bare `fetch(...)` on native. + * On web, NitroFetch still uses the vanilla global fetch implementation. + */ +import {fetch as nitroFetch, Headers as NitroHeaders, Request as NitroRequest, Response as NitroResponse} from 'react-native-nitro-fetch'; + +globalThis.fetch = nitroFetch; +globalThis.Headers = NitroHeaders; +globalThis.Request = NitroRequest; +globalThis.Response = NitroResponse; diff --git a/src/polyfills/NitroFetch.web.ts b/src/polyfills/NitroFetch.web.ts new file mode 100644 index 000000000000..de04469c65ee --- /dev/null +++ b/src/polyfills/NitroFetch.web.ts @@ -0,0 +1 @@ +// On web, we don't use NitroFetch, so no polyfill needed diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts index 93b310b50a9c..5b687b7d2b08 100644 --- a/src/types/onyx/Session.ts +++ b/src/types/onyx/Session.ts @@ -13,7 +13,7 @@ type Session = { email?: string; /** Currently logged in user authToken */ - authToken?: string; + [CONST.HTTP_HEADER_NAMES.AUTH_TOKEN]?: string; /** Currently logged in user authToken type */ authTokenType?: ValueOf; diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 18eb30329aa2..f3b0649a48b5 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -1,3 +1,4 @@ +/* eslint-disable rulesdir/no-multiple-api-calls */ // cspell:ignore SOMESECRETKEY import {beforeEach, jest, test} from '@jest/globals'; @@ -29,6 +30,7 @@ import type {Credentials, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {openAuthSessionAsync} from 'expo-web-browser'; +import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch'; import Onyx from 'react-native-onyx'; import * as TestHelper from '../utils/TestHelper'; @@ -453,6 +455,36 @@ describe('Session', () => { expect(getAllPersistedRequests().length).toBe(0); }); + test('SignOut should clear native startup prefetch state', async () => { + await TestHelper.signInWithTestUser(); + setHasRadio(false); + await waitForBatchedUpdates(); + + await SessionUtil.signOut({authToken: 'testAuthToken'}); + + expect(clearTokenRefresh).toHaveBeenCalledWith('fetch'); + expect(removeAllFromAutoprefetch).toHaveBeenCalled(); + + setHasRadio(true); + await waitForBatchedUpdates(); + }); + + test('SignOut should clear native startup prefetch state before LOG_OUT', async () => { + const clearTokenRefreshMock = jest.mocked(clearTokenRefresh); + const removeAllFromAutoprefetchMock = jest.mocked(removeAllFromAutoprefetch); + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); + + await SessionUtil.signOut({authToken: 'testAuthToken'}); + + expect(clearTokenRefreshMock).toHaveBeenCalledWith('fetch'); + expect(removeAllFromAutoprefetchMock).toHaveBeenCalled(); + expect(makeRequestSpy).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, expect.objectContaining({authToken: 'testAuthToken'}), {}); + expect(clearTokenRefreshMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); + expect(removeAllFromAutoprefetchMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); + + makeRequestSpy.mockRestore(); + }); + describe('SignOutAndRedirectToSignIn', () => { test('SignOutAndRedirectToSignIn should redirect to OldDot when LogOut returns truthy hasOldDotAuthCookies', async () => { await TestHelper.signInWithTestUser(); @@ -659,7 +691,7 @@ describe('Session', () => { await waitForBatchedUpdates(); mockedOpenAuthSessionAsync.mockClear(); - // eslint-disable-next-line rulesdir/no-multiple-api-calls + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); mockedGetPlatform.mockReturnValue(CONST.PLATFORM.ANDROID); @@ -677,7 +709,7 @@ describe('Session', () => { await waitForBatchedUpdates(); mockedOpenAuthSessionAsync.mockImplementationOnce(() => Promise.reject(new Error('Browser session failed'))); - // eslint-disable-next-line rulesdir/no-multiple-api-calls + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); await SessionUtil.signOut({signedInWithSAML: true, authToken: 'testAuthToken', autoGeneratedLogin: 'testLogin'}); @@ -692,7 +724,7 @@ describe('Session', () => { await waitForBatchedUpdates(); mockedOpenAuthSessionAsync.mockClear(); - // eslint-disable-next-line rulesdir/no-multiple-api-calls + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); await SessionUtil.signOut({authToken: 'testAuthToken', autoGeneratedLogin: 'testLogin'}); @@ -706,7 +738,6 @@ describe('Session', () => { describe('replaceTwoFactorDevice', () => { test('sets isLoading and clears errors optimistically for both steps', () => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); SessionUtil.replaceTwoFactorDevice('verify_old', '123456'); @@ -719,7 +750,6 @@ describe('Session', () => { }); test('verify_old success data does not clear twoFactorAuthSecretKey', () => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); SessionUtil.replaceTwoFactorDevice('verify_old', '123456'); @@ -732,7 +762,6 @@ describe('Session', () => { }); test('verify_new success data clears twoFactorAuthSecretKey to signal step completion', () => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); SessionUtil.replaceTwoFactorDevice('verify_new', '654321'); diff --git a/tests/unit/APITest.ts b/tests/unit/APITest.ts index adef4340fbe2..07beac2e6128 100644 --- a/tests/unit/APITest.ts +++ b/tests/unit/APITest.ts @@ -1,5 +1,5 @@ import {initReconnect} from '@libs/actions/Reconnect'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS, AUTHENTICATION_COMMAND} from '@libs/API/types'; import type {ApiRequestCommandParameters} from '@libs/API/types'; import CONST from '@src/CONST'; @@ -363,7 +363,7 @@ describe('APITests', () => { const [commandName2] = call2; const [commandName3] = call3; expect(commandName1).toBe(MOCK_COMMAND); - expect(commandName2).toBe('Authenticate'); + expect(commandName2).toBe(AUTHENTICATION_COMMAND); expect(commandName3).toBe(MOCK_COMMAND); }) ); @@ -514,7 +514,7 @@ describe('APITests', () => { // Third command should be the call to Authenticate const [thirdCommand] = xhr.mock.calls.at(2) ?? []; - expect(thirdCommand).toBe('Authenticate'); + expect(thirdCommand).toBe(AUTHENTICATION_COMMAND); const [fourthCommand] = xhr.mock.calls.at(3) ?? []; expect(fourthCommand).toBe(MOCK_COMMAND); diff --git a/tests/unit/AuthPrefetchTest.ts b/tests/unit/AuthPrefetchTest.ts new file mode 100644 index 000000000000..379e7a1fc2b1 --- /dev/null +++ b/tests/unit/AuthPrefetchTest.ts @@ -0,0 +1,69 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +const mockClearPrefetchOnAppStart = jest.fn(); +const mockMerge = jest.fn(); +const mockMultiSet = jest.fn(); +const mockClear = jest.fn(); +const mockGetDBTime = jest.fn(); + +jest.mock('@libs/Prefetch/clearPrefetchOnAppStart', () => mockClearPrefetchOnAppStart); +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + }, +})); +jest.mock('@libs/DateUtils', () => ({ + getDBTime: mockGetDBTime, +})); +jest.mock('react-native-onyx', () => ({ + __esModule: true, + default: { + merge: mockMerge, + multiSet: mockMultiSet, + clear: mockClear, + }, +})); + +describe('auth startup prefetch cleanup', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockClearPrefetchOnAppStart.mockResolvedValue(undefined); + mockMerge.mockResolvedValue(undefined); + mockMultiSet.mockResolvedValue(undefined); + mockClear.mockResolvedValue(undefined); + mockGetDBTime.mockReturnValue('2026-07-16 12:00:00.000'); + }); + + it('clears native startup prefetches before replacing session auth tokens', async () => { + const creationDate = 123456789; + const getTimeSpy = jest.spyOn(Date.prototype, 'getTime').mockReturnValue(creationDate); + const updateSessionAuthTokens = (await import('@userActions/Session/updateSessionAuthTokens')).default; + + await updateSessionAuthTokens('authToken', 'encryptedAuthToken'); + + expect(mockClearPrefetchOnAppStart).toHaveBeenCalledTimes(1); + expect(mockMerge).toHaveBeenCalledWith(ONYXKEYS.SESSION, { + authToken: 'authToken', + encryptedAuthToken: 'encryptedAuthToken', + creationDate, + }); + expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(0)).toBeLessThan(mockMerge.mock.invocationCallOrder.at(0) ?? 0); + getTimeSpy.mockRestore(); + }); + + it('clears native startup prefetches before resetting Onyx for an identity transition', async () => { + const clearOnyxAndSeedFullReconnect = (await import('@userActions/clearOnyxAndSeedFullReconnect')).default; + + await clearOnyxAndSeedFullReconnect([ONYXKEYS.SESSION], {[ONYXKEYS.IS_LOADING_APP]: true}); + + expect(mockClearPrefetchOnAppStart).toHaveBeenCalledTimes(2); + expect(mockMultiSet).toHaveBeenCalledWith({ + [ONYXKEYS.IS_LOADING_APP]: true, + [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: '2026-07-16 12:00:00.000', + }); + expect(mockClear).toHaveBeenCalledWith([ONYXKEYS.SESSION, ONYXKEYS.IS_LOADING_APP, ONYXKEYS.LAST_FULL_RECONNECT_TIME]); + expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(0)).toBeLessThan(mockMultiSet.mock.invocationCallOrder.at(0) ?? 0); + expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(1)).toBeGreaterThan(mockClear.mock.invocationCallOrder.at(0) ?? 0); + }); +}); diff --git a/tests/unit/NetworkTest.tsx b/tests/unit/NetworkTest.tsx index 0a779dfef6e4..38762c2ffeb9 100644 --- a/tests/unit/NetworkTest.tsx +++ b/tests/unit/NetworkTest.tsx @@ -1,5 +1,6 @@ import {reconnectApp} from '@libs/actions/App'; import * as Reconnect from '@libs/actions/Reconnect'; +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; import {reset as resetFailureTracker} from '@libs/FailureTracker'; import {resetReauthentication} from '@libs/Middleware/Reauthentication'; @@ -119,7 +120,7 @@ describe('NetworkTests', () => { // Verify: // 1. We attempted to authenticate twice (first failed, retry succeeded) // 2. The session has the new auth token (user wasn't logged out) - const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === 'Authenticate'); + const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === AUTHENTICATION_COMMAND); expect(callsToAuthenticate.length).toBe(2); expect(sessionState?.authToken).toBe(NEW_AUTH_TOKEN); }); @@ -182,7 +183,7 @@ describe('NetworkTests', () => { // 5. Authentication Start - Verify authenticate was triggered await waitForBatchedUpdates(); const secondCall = mockedXhr.mock.calls.at(1) as [string, Record]; - expect(secondCall[0]).toBe('Authenticate'); + expect(secondCall[0]).toBe(AUTHENTICATION_COMMAND); // 6. Network State Change - Set offline and back online while authenticate is pending setHasRadio(false); @@ -247,7 +248,7 @@ describe('NetworkTests', () => { .then(() => { // Verify: 3 calls to the API, 1 authenticate call, and reconnect was triggered const callsToOpenPublicProfilePage = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === 'OpenPublicProfilePage'); - const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === 'Authenticate'); + const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === AUTHENTICATION_COMMAND); expect(callsToOpenPublicProfilePage.length).toBe(3); expect(callsToAuthenticate.length).toBe(1); expect(reconnectSpy).toHaveBeenCalled(); diff --git a/tests/unit/PreparePrefetchRequestTest.ts b/tests/unit/PreparePrefetchRequestTest.ts new file mode 100644 index 000000000000..0e0f944adddf --- /dev/null +++ b/tests/unit/PreparePrefetchRequestTest.ts @@ -0,0 +1,40 @@ +import {WRITE_COMMANDS} from '@libs/API/types'; + +const mockGetAccountID = jest.fn(); + +jest.mock('@libs/Network/NetworkStore', () => ({ + getAccountID: mockGetAccountID, +})); + +describe('preparePrefetchRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses an account-scoped key for startup prefetches', async () => { + // Given a signed-in account, because native prefetch responses outlive the JS session + mockGetAccountID.mockReturnValue(123); + + // When a command eligible for startup prefetching is prepared + const preparePrefetchRequest = (await import('@libs/Prefetch/preparePrefetchRequest')).default; + const result = preparePrefetchRequest(WRITE_COMMANDS.RECONNECT_APP); + + // Then its cache key is bound to that account and cannot be reused by another account + expect(result).toEqual({ + prefetchKey: `${WRITE_COMMANDS.RECONNECT_APP}:123`, + prefetchHeaders: {prefetchKey: `${WRITE_COMMANDS.RECONNECT_APP}:123`}, + }); + }); + + it('does not register a startup prefetch without an account identity', async () => { + // Given the previous account's session has been cleared during sign-out + mockGetAccountID.mockReturnValue(null); + + // When a reconnect request is processed after that cleanup + const preparePrefetchRequest = (await import('@libs/Prefetch/preparePrefetchRequest')).default; + const result = preparePrefetchRequest(WRITE_COMMANDS.RECONNECT_APP); + + // Then it cannot recreate a cross-session prefetch entry + expect(result).toEqual({prefetchKey: undefined, prefetchHeaders: undefined}); + }); +}); diff --git a/tests/unit/SessionUtilsTest.ts b/tests/unit/SessionUtilsTest.ts index 3dae53dbebf4..c1d71e5bcbe8 100644 --- a/tests/unit/SessionUtilsTest.ts +++ b/tests/unit/SessionUtilsTest.ts @@ -1,4 +1,4 @@ -import {checkIfShouldUseNewPartnerName, isAgentEmail, isLoggingInAsDelegate} from '@src/libs/SessionUtils'; +import {checkIfShouldUseNewPartnerName, getPartnerCredentials, isAgentEmail, isLoggingInAsDelegate} from '@src/libs/SessionUtils'; function mockHybridAppConfig(isHybridApp: boolean): () => void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -69,6 +69,30 @@ describe('SessionUtils', () => { }); }); + describe('getPartnerCredentials', () => { + test.each([ + ['should return new partner credentials when not in HybridApp', false, 'any-user-id'], + ['should return new partner credentials for expensify.cash- prefix when in HybridApp', true, 'expensify.cash-12345'], + ['should return legacy partner credentials for legacy partnerUserID when in HybridApp', true, 'legacy-user-12345'], + ])('%s', (_description, isHybridApp, partnerUserID) => { + const cleanup = mockHybridAppConfig(isHybridApp); + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const CONFIG = require('@src/CONFIG'); + const useNewPartnerName = checkIfShouldUseNewPartnerName(partnerUserID); + const {partnerName, partnerPassword} = getPartnerCredentials(partnerUserID); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(partnerName).toBe(useNewPartnerName ? CONFIG.default.EXPENSIFY.PARTNER_NAME : CONFIG.default.EXPENSIFY.LEGACY_PARTNER_NAME); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(partnerPassword).toBe(useNewPartnerName ? CONFIG.default.EXPENSIFY.PARTNER_PASSWORD : CONFIG.default.EXPENSIFY.LEGACY_PARTNER_PASSWORD); + } finally { + cleanup(); + } + }); + }); + describe('isLoggingInAsDelegate', () => { test.each([ ['should return false when transitionURL is undefined', undefined, false],