From 4e74011ae2705338ff3b04a23443784324f0672c Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:20:36 +0300 Subject: [PATCH 01/15] MOBILE-130: Route operations endpoints to configurable anonymizer URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional `operationsDomain` to `MBConfiguration` so projects served via a CDP anonymizer can route operations and track-visit requests to a separate host, avoiding personal-data leaks to `api.mindbox.ru`. Affected routes (now resolve to `operationsDomain` when configured): - `/v3/operations/{async,sync,async-custom}` - `/v1.1/customer/mobile-track-visit` - SDK logs upload Config-fetch (`/mobile/byendpoint/*.json`) and `/geo` keep using `domain`. Priority of resolution: 1. `settings.baseAddresses.operations` from the mobile JSON config 2. `operationsDomain` passed to `Mindbox.initialization(...)` 3. `nil` → fall back to `MBConfiguration.domain` (existing behavior) The JSON-sourced value is persisted in `UserDefaults` so operations stay pinned to the anonymizer across restarts. An explicit `null` / empty value in a fresh config clears the cache (rollback channel); a format-broken value preserves the previous good one. Backwards compatibility: - `operationsDomain` has a default value of `nil` - `softReset()` preserves `operationsDomainFromConfig` (PD safety on migration resets) - Legacy `MBConfiguration` JSON without the new key decodes fine Tests cover `URLRequestBuilder` host resolution, `MBConfiguration` validation, `Settings` JSON decoding (incl. `null`/empty rollback), `ConfigValidation` behavior, `MBNetworkFetcher` priority resolution, `OperationsDomainConfigPolicy` decision matrix, persistence lifecycle, and legacy-config decoding. Follow-ups (separate commits): - Drop dead `SDKLogsRoute` - Rewrite `URLValidator` (hardcoded TLD list, `&` bug) - Allow optional `https://` scheme in `domain` / `operationsDomain` inputs --- Mindbox.xcodeproj/project.pbxproj | 12 + .../InAppConfigurationManager.swift | 25 +- .../OperationsDomainConfigPolicy.swift | 36 ++ .../Models/Config/BaseAddressesModel.swift | 17 + .../Models/Config/SettingsModel.swift | 4 +- Mindbox/MBConfiguration.swift | 25 +- Mindbox/MindboxLogger/SDKLogsRequest.swift | 1 + Mindbox/Network/Abstract/Route.swift | 12 + .../Network/Helpers/URLRequestBuilder.swift | 17 +- Mindbox/Network/MBNetworkFetcher.swift | 29 +- .../NetworkRepository/Event/EventRoute.swift | 2 + .../MBPersistenceStorage.swift | 8 + .../PersistenceStorage.swift | 9 + MindboxTests/Extensions/Tag+Extensions.swift | 1 + .../InAppConfigStub.swift | 5 +- .../InappTTLTests.swift | 16 +- .../Mock/MockPersistenceStorage.swift | 6 + .../Network/OperationsURLRoutingTests.swift | 327 ++++++++++++++++++ 18 files changed, 534 insertions(+), 18 deletions(-) create mode 100644 Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift create mode 100644 Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift create mode 100644 MindboxTests/Network/OperationsURLRoutingTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 57265de33..8956fa196 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -11,11 +11,13 @@ 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; 313B234125ADEA0F00A1CB72 /* Mindbox.h in Headers */ = {isa = PBXBuildFile; fileRef = 313B233325ADEA0F00A1CB72 /* Mindbox.h */; settings = {ATTRIBUTES = (Public, ); }; }; 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */; }; + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */; }; 314B390025AEE96F00E947B9 /* CoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FF25AEE96F00E947B9 /* CoreController.swift */; }; 317054CB25AF189800AE624C /* PersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317054CA25AF189800AE624C /* PersistenceStorage.swift */; }; 317AF8FC25B844DB006348FA /* UtilitiesFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */; }; @@ -477,6 +479,7 @@ F31470962B96681F00E01E5C /* 27-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470952B96681F00E01E5C /* 27-TargetingRequests.json */; }; F31470982B9668F100E01E5C /* 31-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470972B9668F100E01E5C /* 31-TargetingRequests.json */; }; F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F315503E2BBB24E20072A071 /* TTLValidationService.swift */; }; + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */; }; F31909992E979D9E00373E2F /* MindboxAppDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */; }; F31A94782BC6995500E6C978 /* InappFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A94772BC6995500E6C978 /* InappFrequency.swift */; }; F31A947C2BC69E3900E6C978 /* PeriodicFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */; }; @@ -739,6 +742,7 @@ 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxTests.swift; sourceTree = ""; }; 313B234025ADEA0F00A1CB72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfiguration.swift; sourceTree = ""; }; + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAddressesModel.swift; sourceTree = ""; }; 314B38FF25AEE96F00E947B9 /* CoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreController.swift; sourceTree = ""; }; 317054CA25AF189800AE624C /* PersistenceStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStorage.swift; sourceTree = ""; }; 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesFetcher.swift; sourceTree = ""; }; @@ -1056,6 +1060,7 @@ 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsURLRoutingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppCoreManager.swift; sourceTree = ""; }; 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationManager.swift; sourceTree = ""; }; @@ -1203,6 +1208,7 @@ F31470952B96681F00E01E5C /* 27-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "27-TargetingRequests.json"; sourceTree = ""; }; F31470972B9668F100E01E5C /* 31-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "31-TargetingRequests.json"; sourceTree = ""; }; F315503E2BBB24E20072A071 /* TTLValidationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTLValidationService.swift; sourceTree = ""; }; + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsDomainConfigPolicy.swift; sourceTree = ""; }; F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxAppDelegateProxy.swift; sourceTree = ""; }; F31A94772BC6995500E6C978 /* InappFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequency.swift; sourceTree = ""; }; F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeriodicFrequency.swift; sourceTree = ""; }; @@ -2256,6 +2262,7 @@ isa = PBXGroup; children = ( 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, ); name = Network; path = Network; @@ -3111,6 +3118,7 @@ isa = PBXGroup; children = ( F315503E2BBB24E20072A071 /* TTLValidationService.swift */, + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */, ); path = Services; sourceTree = ""; @@ -3715,6 +3723,7 @@ F3A8B9AA2A3A719C00E9C055 /* ABTestModel.swift */, F3A4EFDB2D5224C700DB96A8 /* SlidingExpirationModel.swift */, 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */, + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */, ); path = Config; sourceTree = ""; @@ -4385,6 +4394,7 @@ F3A8B9A32A3A6E6900E9C055 /* SdkVersionModel.swift in Sources */, 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */, F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */, + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */, 334F3AF5264C199900A6AC00 /* CodableDictionary.swift in Sources */, F3F5BB8A2B79F2600022AC3F /* PushNotificationFormatter.swift in Sources */, 334F3AF3264C199900A6AC00 /* AreaRequest.swift in Sources */, @@ -4554,6 +4564,7 @@ 6FDD1445266F7C2200A50C35 /* CouponResponse.swift in Sources */, F78E92EF282E63320003B4A3 /* DispatchSemaphore.swift in Sources */, 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */, + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */, F331DD0C2A83A56500222120 /* ViewFactoryProtocol.swift in Sources */, 6FDD1447266F7C2B00A50C35 /* LimitResponse.swift in Sources */, 847F580725C88C7A00147A9A /* NetworkFetcher.swift in Sources */, @@ -4722,6 +4733,7 @@ 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */, 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */, 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 606c4cc9b..9d3ad9e51 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -156,16 +156,37 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { if let viewProduct = settings.operations?.viewProduct { SessionTemporaryStorage.shared.viewProductOperation = viewProduct.systemName.lowercased() } - + if let inappSettings = settings.inapp { SessionTemporaryStorage.shared.inAppSettings = inappSettings } featureToggleManager.applyFeatureToggles(settings.featureToggles) - + + persistOperationsDomain(from: settings.baseAddresses) + saveConfigSessionToCache(settings.slidingExpiration?.config) } + private func persistOperationsDomain(from baseAddresses: Settings.BaseAddresses?) { + let current = persistenceStorage.operationsDomainFromConfig + let raw = baseAddresses?.operations + + switch OperationsDomainConfigPolicy.action(for: raw, currentlyStored: current) { + case .keep: + if let raw = raw, !raw.isEmpty, current != raw { + // `.keep` on a non-empty value means it was rejected by URLValidator. + Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(raw)", level: .error, category: .inAppMessages) + } + case .clear: + persistenceStorage.operationsDomainFromConfig = nil + Logger.common(message: "[OperationsDomain] Cleared — config has no value.", level: .info, category: .inAppMessages) + case .save(let value): + persistenceStorage.operationsDomainFromConfig = value + Logger.common(message: "[OperationsDomain] Updated from config. [Value]: \(value)", level: .info, category: .inAppMessages) + } + } + private func createTTLValidationService() -> TTLValidationProtocol { return TTLValidationService(persistenceStorage: self.persistenceStorage) } diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift new file mode 100644 index 000000000..bec7e4616 --- /dev/null +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -0,0 +1,36 @@ +// +// OperationsDomainConfigPolicy.swift +// Mindbox +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Decides save / clear / keep for the operations host coming from JSON config. +/// Extracted from `InAppConfigurationManager` so it can be unit-tested in isolation. +enum OperationsDomainConfigPolicy { + + enum Action: Equatable { + case save(String) + /// Config explicitly cleared the value (null / missing / empty). + case clear + /// No-op: equal to stored, both empty, or incoming value is format-broken + /// (one bad push must not destroy a working config). + case keep + } + + static func action(for raw: String?, currentlyStored: String?) -> Action { + guard let value = raw, !value.isEmpty else { + return currentlyStored == nil ? .keep : .clear + } + + guard let url = URL(string: "https://" + value), + URLValidator(url: url).evaluate() else { + return .keep + } + + return value == currentlyStored ? .keep : .save(value) + } +} diff --git a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift new file mode 100644 index 000000000..16acc0dad --- /dev/null +++ b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift @@ -0,0 +1,17 @@ +// +// BaseAddressesModel.swift +// Mindbox +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +extension Settings { + /// DTO for `settings.baseAddresses` in the mobile JSON config + /// (`/mobile/byendpoint/{endpointId}.json`). + struct BaseAddresses: Decodable, Equatable { + let operations: String? + } +} diff --git a/Mindbox/InAppMessages/Models/Config/SettingsModel.swift b/Mindbox/InAppMessages/Models/Config/SettingsModel.swift index d8c51260c..120cc814f 100644 --- a/Mindbox/InAppMessages/Models/Config/SettingsModel.swift +++ b/Mindbox/InAppMessages/Models/Config/SettingsModel.swift @@ -14,9 +14,10 @@ struct Settings: Decodable, Equatable { let slidingExpiration: SlidingExpiration? let inapp: InAppSettings? let featureToggles: FeatureToggles? + let baseAddresses: BaseAddresses? enum CodingKeys: CodingKey { - case operations, ttl, slidingExpiration, inapp, featureToggles + case operations, ttl, slidingExpiration, inapp, featureToggles, baseAddresses } } @@ -28,5 +29,6 @@ extension Settings { self.slidingExpiration = try? container.decodeIfPresent(SlidingExpiration.self, forKey: .slidingExpiration) self.inapp = try? container.decodeIfPresent(InAppSettings.self, forKey: .inapp) self.featureToggles = try? container.decodeIfPresent(FeatureToggles.self, forKey: .featureToggles) + self.baseAddresses = try? container.decodeIfPresent(BaseAddresses.self, forKey: .baseAddresses) } } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index f4490f6c5..1091c6534 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -15,6 +15,7 @@ import MindboxLogger public struct MBConfiguration: Codable { public let endpoint: String public let domain: String + public var operationsDomain: String? public var previousInstallationId: String? public var previousDeviceUUID: String? public var subscribeCustomerIfCreated: Bool @@ -31,6 +32,9 @@ public struct MBConfiguration: Codable { /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. /// - Parameter shouldCreateCustomer: Flag which determines create or not anonymous users. Usable only during first initialisation. Default value is `true`. /// - Parameter uuidDebugEnabled: Flag which determines if uuid debugging functionality is enabled. Default value is `true`. + /// - Parameter operationsDomain: Optional anonymizer host for `/v3/operations/*` and + /// `/v1.1/customer/mobile-track-visit`. Bare host without scheme. Overridden by the + /// value from the mobile JSON config when present. Default `nil` (use `domain`). /// /// - Throws:`MindboxError.internalError` for invalid initialization parameters public init( @@ -41,7 +45,8 @@ public struct MBConfiguration: Codable { subscribeCustomerIfCreated: Bool = false, shouldCreateCustomer: Bool = true, imageLoadingMaxTimeInSeconds: Double? = nil, - uuidDebugEnabled: Bool = true + uuidDebugEnabled: Bool = true, + operationsDomain: String? = nil ) throws { self.endpoint = endpoint self.domain = domain @@ -58,6 +63,17 @@ public struct MBConfiguration: Codable { throw error } + if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { + guard let url = URL(string: "https://" + operationsDomain), URLValidator(url: url).evaluate() else { + let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)")) + Logger.error(error.asLoggerError()) + throw error + } + self.operationsDomain = operationsDomain + } else { + self.operationsDomain = nil + } + if let previousInstallationId = previousInstallationId, !previousInstallationId.isEmpty { if UUID(uuidString: previousInstallationId) != nil && UDIDValidator(udid: previousInstallationId).evaluate() { self.previousInstallationId = previousInstallationId @@ -137,6 +153,7 @@ public struct MBConfiguration: Codable { enum CodingKeys: String, CodingKey { case endpoint case domain + case operationsDomain case previousInstallationId case previousDeviceUUID case subscribeCustomerIfCreated @@ -148,6 +165,7 @@ public struct MBConfiguration: Codable { let values = try decoder.container(keyedBy: CodingKeys.self) let endpoint = try values.decode(String.self, forKey: .endpoint) let domain = try values.decode(String.self, forKey: .domain) + let operationsDomain = try? values.decodeIfPresent(String.self, forKey: .operationsDomain) var previousInstallationId: String? if let value = try? values.decode(String.self, forKey: .previousInstallationId) { if !value.isEmpty { @@ -170,7 +188,8 @@ public struct MBConfiguration: Codable { previousDeviceUUID: previousDeviceUUID, subscribeCustomerIfCreated: subscribeCustomerIfCreated, shouldCreateCustomer: shouldCreateCustomer, - uuidDebugEnabled: uuidDebugEnabled + uuidDebugEnabled: uuidDebugEnabled, + operationsDomain: operationsDomain ) } } @@ -191,6 +210,8 @@ struct ConfigValidation { var changedState: ChangedState = .none + // `operationsDomain` is intentionally not diffed: changing it must not re-fire + // `installed`. The new value is picked up at request time via MBNetworkFetcher. mutating func compare(_ lhs: MBConfiguration?, _ rhs: MBConfiguration?) { if !(lhs?.domain == rhs?.domain && lhs?.endpoint == rhs?.endpoint) { changedState = .rest diff --git a/Mindbox/MindboxLogger/SDKLogsRequest.swift b/Mindbox/MindboxLogger/SDKLogsRequest.swift index cf228db30..a952d9930 100644 --- a/Mindbox/MindboxLogger/SDKLogsRequest.swift +++ b/Mindbox/MindboxLogger/SDKLogsRequest.swift @@ -19,6 +19,7 @@ struct SDKLogsRoute: Route { var headers: HTTPHeaders? { nil } var queryParameters: QueryParameters { .init() } var body: Data? + var baseURLKind: RouteBaseURL { .operations } func makeBasicQueryParameters(with wrapper: EventWrapper) -> QueryParameters { ["transactionId": wrapper.event.transactionId, diff --git a/Mindbox/Network/Abstract/Route.swift b/Mindbox/Network/Abstract/Route.swift index 9712ef439..88b4fa1b1 100644 --- a/Mindbox/Network/Abstract/Route.swift +++ b/Mindbox/Network/Abstract/Route.swift @@ -8,6 +8,12 @@ import Foundation +enum RouteBaseURL { + case domain + /// Falls back to `domain` when no operations host is configured. + case operations +} + protocol Route { var method: HTTPMethod { get } @@ -19,4 +25,10 @@ protocol Route { var queryParameters: QueryParameters { get } var body: Data? { get } + + var baseURLKind: RouteBaseURL { get } +} + +extension Route { + var baseURLKind: RouteBaseURL { .domain } } diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 0b4d53b61..86cd71810 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -12,6 +12,12 @@ import MindboxLogger struct URLRequestBuilder { let domain: String + let operationsDomain: String? + + init(domain: String, operationsDomain: String? = nil) { + self.domain = domain + self.operationsDomain = operationsDomain + } func asURLRequest(route: Route) throws -> URLRequest { let components = makeURLComponents(for: route) @@ -34,13 +40,22 @@ struct URLRequestBuilder { private func makeURLComponents(for route: Route) -> URLComponents { var components = URLComponents() components.scheme = "https" - components.host = domain + components.host = resolvedHost(for: route) components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) return components } + private func resolvedHost(for route: Route) -> String { + switch route.baseURLKind { + case .domain: + return domain + case .operations: + return operationsDomain ?? domain + } + } + private func makeQueryItems(for parameters: QueryParameters?) -> [URLQueryItem]? { return parameters?.compactMap { URLQueryItem(name: $0.key, value: $0.value.description) } } diff --git a/Mindbox/Network/MBNetworkFetcher.swift b/Mindbox/Network/MBNetworkFetcher.swift index 9da733fda..123bee386 100644 --- a/Mindbox/Network/MBNetworkFetcher.swift +++ b/Mindbox/Network/MBNetworkFetcher.swift @@ -56,7 +56,10 @@ class MBNetworkFetcher: NetworkFetcher { return } - let builder = URLRequestBuilder(domain: configuration.domain) + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) do { let urlRequest = try builder.asURLRequest(route: route) Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) @@ -102,7 +105,10 @@ class MBNetworkFetcher: NetworkFetcher { completion(.failure(error)) return } - let builder = URLRequestBuilder(domain: configuration.domain) + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) do { let urlRequest = try builder.asURLRequest(route: route) Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) @@ -368,4 +374,23 @@ class MBNetworkFetcher: NetworkFetcher { tasks.forEach { $0.cancel() } } } + + private func resolvedOperationsDomain(configuration: MBConfiguration) -> String? { + Self.resolveOperationsDomain( + fromConfigJSON: persistenceStorage.operationsDomainFromConfig, + fromInit: configuration.operationsDomain + ) + } + + /// Priority: JSON config > init > nil. Empty strings count as "no value". + /// Static for unit-testing without a PersistenceStorage or MBConfiguration. + static func resolveOperationsDomain(fromConfigJSON: String?, fromInit: String?) -> String? { + if let fromConfig = fromConfigJSON, !fromConfig.isEmpty { + return fromConfig + } + if let fromInit = fromInit, !fromInit.isEmpty { + return fromInit + } + return nil + } } diff --git a/Mindbox/NetworkRepository/Event/EventRoute.swift b/Mindbox/NetworkRepository/Event/EventRoute.swift index 940962eb3..9ff8d4c90 100644 --- a/Mindbox/NetworkRepository/Event/EventRoute.swift +++ b/Mindbox/NetworkRepository/Event/EventRoute.swift @@ -21,6 +21,8 @@ enum EventRoute: Route { } } + var baseURLKind: RouteBaseURL { .operations } + var path: String { switch self { case .syncEvent: diff --git a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift index a851dfd53..d35f5a39a 100644 --- a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift @@ -268,6 +268,13 @@ class MBPersistenceStorage: PersistenceStorage { @UserDefaultsWrapper(key: .webViewLocalStateVersion, defaultValue: nil) var webViewLocalStateVersion: Int? + @UserDefaultsWrapper(key: .operationsDomainFromConfig, defaultValue: nil) + var operationsDomainFromConfig: String? { + didSet { + onDidChange?() + } + } + // MARK: - Deprecated Properties // These properties are deprecated and will be removed in future versions. // Please use the recommended alternatives instead. @@ -306,6 +313,7 @@ extension MBPersistenceStorage { case applicationInfoUpdateVersion = "MBPersistenceStorage-applicationInfoUpdatedVersion" case applicationInstanceId = "MBPersistenceStorage-applicationInstanceId" case webViewLocalStateVersion = "MBPersistenceStorage-webViewLocalStateVersion" + case operationsDomainFromConfig = "MBPersistenceStorage-operationsDomainFromConfig" // MARK: - Deprecated Keys // These keys are deprecated and will be removed in future versions. diff --git a/Mindbox/PersistenceStorage/PersistenceStorage.swift b/Mindbox/PersistenceStorage/PersistenceStorage.swift index 95e268a68..45e22e2bf 100644 --- a/Mindbox/PersistenceStorage/PersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/PersistenceStorage.swift @@ -52,6 +52,11 @@ protocol PersistenceStorage: AnyObject { /// It is optional and can be set to `nil` if the configuration has not yet been downloaded yet or reset. var configDownloadDate: Date? { get set } + /// Operations host cached from `settings.baseAddresses.operations` in the mobile + /// JSON config. Persisted across launches; takes precedence over the init-time + /// `MBConfiguration.operationsDomain` at request time. + var operationsDomainFromConfig: String? { get set } + /// The version code used to track the current state of migrations. /// This value is compared to `Constants.Migration.sdkVersionCode` to determine /// if migrations need to be performed. If a migration fails, and the `versionCodeForMigration` @@ -97,6 +102,9 @@ extension PersistenceStorage { func softReset() { configDownloadDate = nil + // `operationsDomainFromConfig` is intentionally preserved: clearing it on a + // migration reset would route operations to `domain` until config reloads, + // breaking the PD-safety guarantee. shownDatesByInApp = nil handledlogRequestIds = nil lastInappStateChangeDate = nil @@ -119,6 +127,7 @@ extension PersistenceStorage { configuration = nil isNotificationsEnabled = nil configDownloadDate = nil + operationsDomainFromConfig = nil applicationInstanceId = nil applicationInfoUpdateVersion = nil } diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 27b506597..0cd6600d6 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -25,4 +25,5 @@ extension Tag { @Tag static var geoTargeting: Self @Tag static var webView: Self @Tag static var trackVisit: Self + @Tag static var operationsRouting: Self } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift index 1e54fa63c..be0e2dbd9 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift @@ -115,10 +115,11 @@ extension InAppConfigStub { viewCategory: operationType == .viewCategory ? .init(systemName: "Mobile.ViewCategory") : nil, setCart: nil ), - ttl: nil, + ttl: nil, slidingExpiration: nil, inapp: nil, - featureToggles: nil + featureToggles: nil, + baseAddresses: nil ) // Mock method setupSettingsFromConfig. diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift index 9a8a77ca3..dc52be6dc 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift @@ -27,7 +27,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTL_Exceeds() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .hour, value: -2, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertTrue(result, "Inapps должны быть сброшены, так как время ttl истекло.") @@ -35,7 +35,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .second, value: -1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "00:00:02"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "00:00:02"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время ttl еще не истекло.") @@ -43,7 +43,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithoutTTL() throws { persistenceStorage.configDownloadDate = Date() - let settings = Settings(operations: nil, ttl: nil, slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: nil, slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как в конфиге отсутствует TTL.") @@ -51,7 +51,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLHalfHourAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .minute, value: -30, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -59,7 +59,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLHalfMinutesAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .second, value: -30, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "00:01:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "00:01:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -67,7 +67,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLOneDayAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -75,7 +75,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithMinusTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: 1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "-2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "-2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -83,7 +83,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithMinusOneDayTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: -2, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "-1.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "-1.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") diff --git a/MindboxTests/Mock/MockPersistenceStorage.swift b/MindboxTests/Mock/MockPersistenceStorage.swift index 3c3ebfdef..f7e587146 100644 --- a/MindboxTests/Mock/MockPersistenceStorage.swift +++ b/MindboxTests/Mock/MockPersistenceStorage.swift @@ -123,4 +123,10 @@ class MockPersistenceStorage: PersistenceStorage { var applicationInstanceId: String? var webViewLocalStateVersion: Int? + + var operationsDomainFromConfig: String? { + didSet { + onDidChange?() + } + } } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift new file mode 100644 index 000000000..2a18103ab --- /dev/null +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -0,0 +1,327 @@ +// +// OperationsURLRoutingTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("Operations URL routing", .tags(.operationsRouting)) +struct OperationsURLRoutingTests { + + private let domain = "api.mindbox.ru" + private let opsHost = "anonymizer-api-regular.client.ru" + + // MARK: - URLRequestBuilder host resolution + + @Test("Event routes use operationsDomain when configured") + func eventRoutesUseOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let wrapper = Self.makeEventWrapper(.installed) + + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == opsHost) + #expect(try builder.asURLRequest(route: EventRoute.customAsyncEvent(wrapper)).url?.host == opsHost) + #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == opsHost) + + let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) + #expect(try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url?.host == opsHost) + } + + @Test("SDKLogsRoute uses operationsDomain when configured") + func sdkLogsRouteUsesOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let url = try builder.asURLRequest(route: SDKLogsRoute()).url + #expect(url?.host == opsHost) + } + + @Test("Config and geo routes always use domain") + func domainRoutesIgnoreOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + + let geoURL = try builder.asURLRequest(route: FetchInAppGeoRoute()).url + #expect(geoURL?.host == domain) + #expect(geoURL?.path == "/geo") + } + + @Test("No operationsDomain → all routes fall back to domain (backwards compatibility)") + func noOperationsDomainFallsBackToDomain() throws { + let builder = URLRequestBuilder(domain: domain) + let wrapper = Self.makeEventWrapper(.installed) + + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: SDKLogsRoute()).url?.host == domain) + #expect(try builder.asURLRequest(route: FetchInAppGeoRoute()).url?.host == domain) + } + + @Test("Path and query parameters survive host swap") + func pathAndQueryUnchangedOnHostSwap() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/v3/operations/async") + #expect(url?.query?.contains("operation=MobilePush.ApplicationInstalled") == true) + #expect(url?.query?.contains("endpointId=test-endpoint") == true) + } + + // MARK: - MBConfiguration validation + + @Test("MBConfiguration accepts nil operationsDomain (backwards compatible)") + func configAcceptsNilOperationsDomain() throws { + let config = try MBConfiguration(endpoint: "e", domain: domain) + #expect(config.operationsDomain == nil) + } + + @Test("MBConfiguration accepts valid operationsDomain") + func configAcceptsValidOperationsDomain() throws { + let config = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: opsHost + ) + #expect(config.operationsDomain == opsHost) + } + + @Test("MBConfiguration treats empty operationsDomain as nil") + func configTreatsEmptyOperationsDomainAsNil() throws { + let config = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "" + ) + #expect(config.operationsDomain == nil) + } + + @Test("MBConfiguration rejects invalid operationsDomain") + func configRejectsInvalidOperationsDomain() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "not a host with spaces" + ) + } + } + + // MARK: - Config JSON decoding (backend schema preserves `baseAddresses.operations`) + + @Test("Settings decodes baseAddresses.operations", .tags(.decoding)) + func settingsDecodesBaseAddresses() throws { + let json = """ + { + "settings": { + "baseAddresses": { "operations": "anonymizer.client.ru" }, + "ttl": { "inapps": "1.00:00:00" } + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses?.operations == "anonymizer.client.ru") + } + + @Test("Settings tolerates missing baseAddresses", .tags(.decoding)) + func settingsWithoutBaseAddresses() throws { + let json = """ + { "settings": { "ttl": { "inapps": "1.00:00:00" } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses == nil) + } + + @Test("Settings decodes explicit null as rollback signal", .tags(.decoding)) + func settingsDecodesNullOperationsAsRollback() throws { + let json = """ + { "settings": { "baseAddresses": { "operations": null } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses != nil) + #expect(response.settings?.baseAddresses?.operations == nil) + } + + @Test("Settings decodes empty string as rollback signal", .tags(.decoding)) + func settingsDecodesEmptyOperationsAsRollback() throws { + let json = """ + { "settings": { "baseAddresses": { "operations": "" } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses?.operations == "") + } + + // MARK: - ConfigValidation.compare + + @Test("ConfigValidation does NOT flag operationsDomain change — new value applies without re-install") + func configValidationIgnoresOperationsDomainChange() throws { + let lhs = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "old.client.ru" + ) + let rhs = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "new.client.ru" + ) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("ConfigValidation still flags domain change as REST (operationsDomain unaffected)") + func configValidationDetectsDomainChange() throws { + let lhs = try MBConfiguration(endpoint: "e", domain: "a.mindbox.ru", + operationsDomain: opsHost) + let rhs = try MBConfiguration(endpoint: "e", domain: "b.mindbox.ru", + operationsDomain: opsHost) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - Priority resolution (MBNetworkFetcher) + + @Test("Priority — JSON wins when both JSON and init are set") + func priorityJSONWinsOverInit() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: "json.example.ru", + fromInit: "init.example.ru" + ) + #expect(resolved == "json.example.ru") + } + + @Test("Priority — init used when JSON has nothing") + func priorityInitUsedWhenJSONMissing() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: "init.example.ru" + ) + #expect(resolved == "init.example.ru") + } + + @Test("Priority — returns nil (→ domain fallback) when neither set") + func priorityNilWhenNeitherSet() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: nil + ) + #expect(resolved == nil) + } + + @Test("Priority — empty-string JSON treated as no value, falls through to init") + func priorityEmptyStringJSONFallsThrough() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: "", + fromInit: "init.example.ru" + ) + #expect(resolved == "init.example.ru") + } + + @Test("Priority — empty-string init also treated as no value") + func priorityEmptyStringInitFallsThrough() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: "" + ) + #expect(resolved == nil) + } + + // MARK: - OperationsDomainConfigPolicy (decides save / clear / keep from JSON) + + @Test("Policy — saves a new valid value when storage is empty") + func policySavesNewValueFromEmpty() { + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("x.ru")) + } + + @Test("Policy — saves when value changes") + func policySavesOnChange() { + #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "old.ru") == .save("new.ru")) + } + + @Test("Policy — keeps when incoming value equals stored") + func policyKeepsOnIdenticalValue() { + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") == .keep) + } + + @Test("Policy — clears on null/missing config when something is stored") + func policyClearsOnNullWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "old.ru") == .clear) + } + + @Test("Policy — clears on empty string when something is stored") + func policyClearsOnEmptyWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "old.ru") == .clear) + } + + @Test("Policy — no-ops when nothing stored and nothing came") + func policyKeepsOnNothingToChange() { + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: nil) == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: nil) == .keep) + } + + @Test("Policy — preserves previous value when incoming host is format-broken") + func policyKeepsOnInvalidFormat() { + #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "good.ru") == .keep) + } + + // MARK: - Persistence lifecycle + + @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") + func softResetPreservesOperationsDomain() { + let storage = MockPersistenceStorage() + storage.operationsDomainFromConfig = "cached-anonymizer.ru" + storage.configDownloadDate = Date() + + storage.softReset() + + #expect(storage.operationsDomainFromConfig == "cached-anonymizer.ru") + #expect(storage.configDownloadDate == nil) + } + + @Test("reset clears operationsDomainFromConfig (test-only hard reset)") + func hardResetClearsOperationsDomain() { + let storage = MockPersistenceStorage() + storage.operationsDomainFromConfig = "cached.ru" + + storage.reset() + + #expect(storage.operationsDomainFromConfig == nil) + } + + // MARK: - Backwards compatibility + + @Test("MBConfiguration decodes legacy JSON without operationsDomain key", .tags(.decoding)) + func decodesLegacyConfigWithoutOperationsDomain() throws { + let legacyJSON = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "subscribeCustomerIfCreated": false, + "shouldCreateCustomer": true, + "uuidDebugEnabled": true + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) + #expect(config.endpoint == "app-IOS") + #expect(config.domain == "api.mindbox.ru") + #expect(config.operationsDomain == nil) + } + + // MARK: - Helpers + + private static func makeEventWrapper( + _ type: Event.Operation, + bodyJSON: String = "{}" + ) -> EventWrapper { + let event = Event(type: type, body: bodyJSON) + return EventWrapper(event: event, endpoint: "test-endpoint", deviceUUID: "F47AC10B-58CC-4372-A567-0E02B2C3D479") + } +} From ff6951b423da4ba1be99f591d8bba9a14a867f00 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:13:11 +0300 Subject: [PATCH 02/15] MOBILE-130: Test settings.baseAddresses parsing from mobile config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `SettingsConfigParsingTests` with `baseAddresses` cases from the Pkl error stubs (`Error`, `TypeError`, `OperationsError`, `OperationsTypeError`) and a positive assertion in `test_SettingsConfig_shouldParseSuccessfully`. Updates JSON fixtures under `SettingsJsonStubs/` to carry the new `baseAddresses` block (sourced from `pkl-mobile-config`); adds `BaseAddressesError/` with four fixtures and wires them into the test bundle via `Mindbox.xcodeproj/project.pbxproj`. Switches `Settings.BaseAddresses` to a custom `init(from:)` with per-field `try?` so a type error on `operations` nils only that field — matching `FeatureToggles`/`SlidingExpiration`/`InAppSettings`. Drops two parsing tests from `OperationsURLRoutingTests` whose coverage is now duplicated (positive parse, missing `baseAddresses`). Rollback-channel cases (`null` and empty string) stay there — they exercise feature behavior, not schema parsing. --- Mindbox.xcodeproj/project.pbxproj | 24 +++++++ .../Models/Config/BaseAddressesModel.swift | 11 ++++ .../Settings/SettingsConfigParsingTests.swift | 62 ++++++++++++++++++- .../SettingsBaseAddressesError.json | 31 ++++++++++ .../SettingsBaseAddressesOperationsError.json | 31 ++++++++++ ...tingsBaseAddressesOperationsTypeError.json | 31 ++++++++++ .../SettingsBaseAddressesTypeError.json | 29 +++++++++ .../SettingsFeatureTogglesError.json | 46 +++++++------- ...eTogglesShouldSendInAppShowErrorFalse.json | 46 +++++++------- ...ogglesShouldSendInAppShowErrorMissing.json | 43 +++++++------ ...glesShouldSendInAppShowErrorTypeError.json | 46 +++++++------- .../SettingsFeatureTogglesTypeError.json | 44 ++++++------- .../SettingsAllOperationsWithErrors.json | 3 + .../SettingsAllOperationsWithTypeErrors.json | 3 + .../SettingsOperationsError.json | 3 + .../SettingsOperationsTypeError.json | 3 + ...OperationsViewCategoryAndSetCartError.json | 3 + ...ViewCategoryAndSetCartSystemNameError.json | 3 + ...ategoryAndSetCartSystemNameMixedError.json | 3 + ...CategoryAndSetCartSystemNameTypeError.json | 3 + ...ationsViewCategoryAndSetCartTypeError.json | 3 + .../SettingsOperationsViewProductError.json | 3 + ...sOperationsViewProductSystemNameError.json | 3 + ...rationsViewProductSystemNameTypeError.json | 3 + ...ettingsOperationsViewProductTypeError.json | 3 + .../SettingsJsonStubs/SettingsConfig.json | 3 + .../SettingsSlidingExpirationConfigError.json | 3 + ...tingsSlidingExpirationConfigTypeError.json | 3 + .../SettingsSlidingExpirationError.json | 3 + ...dingExpirationPushTokenKeepaliveError.json | 3 + ...ExpirationPushTokenKeepaliveTypeError.json | 3 + .../SettingsSlidingExpirationTypeError.json | 3 + .../TtlErrors/SettingsTtlError.json | 3 + .../TtlErrors/SettingsTtlInappsError.json | 3 + .../TtlErrors/SettingsTtlInappsTypeError.json | 3 + .../TtlErrors/SettingsTtlTypeError.json | 3 + .../Network/OperationsURLRoutingTests.swift | 32 ++-------- 37 files changed, 407 insertions(+), 141 deletions(-) create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 8956fa196..564f2c8ac 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -561,6 +561,10 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */; }; + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */; }; + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */; }; + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */; }; F34A45AE2B7628B700634C8B /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AD2B7628B700634C8B /* MBPushNotification.swift */; }; F34A45B02B762A6100634C8B /* MindboxPushValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */; }; F351F1C02CE380A40053423E /* InappMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1BF2CE380A40053423E /* InappMapper.swift */; }; @@ -1290,6 +1294,10 @@ F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesError.json; sourceTree = ""; }; + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesTypeError.json; sourceTree = ""; }; + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsError.json; sourceTree = ""; }; + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsTypeError.json; sourceTree = ""; }; F34A45AD2B7628B700634C8B /* MBPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBPushNotification.swift; sourceTree = ""; }; F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxPushValidator.swift; sourceTree = ""; }; F351F1BF2CE380A40053423E /* InappMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapper.swift; sourceTree = ""; }; @@ -2058,6 +2066,7 @@ 4731A7E72F447C3100CBE1E5 /* SettingsJsonStubs */ = { isa = PBXGroup; children = ( + F3BA10502F500A800065392A /* BaseAddressesError */, F34A10432F455C5B0065392A /* FeatureTogglesError */, 4731A7CB2F447C3100CBE1E5 /* InappError */, 4731A7D92F447C3100CBE1E5 /* OperationsErrors */, @@ -3517,6 +3526,17 @@ path = FeatureTogglesError; sourceTree = ""; }; + F3BA10502F500A800065392A /* BaseAddressesError */ = { + isa = PBXGroup; + children = ( + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */, + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */, + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */, + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */, + ); + path = BaseAddressesError; + sourceTree = ""; + }; F34A45AC2B76286D00634C8B /* PublicModels */ = { isa = PBXGroup; children = ( @@ -4079,6 +4099,10 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */, F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */, F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */, + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */, + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */, + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */, + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */, 4731A7FD2F447C3100CBE1E5 /* InAppDelayTimeTypeError.json in Resources */, 4731A7FE2F447C3100CBE1E5 /* MonitoringLogsTypeError.json in Resources */, 4731A7FF2F447C3100CBE1E5 /* SettingsInAppSettingsMissingMaxInappsPerDay.json in Resources */, diff --git a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift index 16acc0dad..410d2e02c 100644 --- a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift +++ b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift @@ -13,5 +13,16 @@ extension Settings { /// (`/mobile/byendpoint/{endpointId}.json`). struct BaseAddresses: Decodable, Equatable { let operations: String? + + enum CodingKeys: CodingKey { + case operations + } + } +} + +extension Settings.BaseAddresses { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.operations = try? container.decodeIfPresent(String.self, forKey: .operations) } } diff --git a/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift b/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift index 65879a365..9637445a1 100644 --- a/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift +++ b/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift @@ -65,6 +65,13 @@ fileprivate enum SettingsConfig: String, Configurable { case settingsInAppSettingsMissingMinIntervalBetweenShows = "SettingsInAppSettingsMissingMinIntervalBetweenShows" // Missing minIntervalBetweenShows case settingsInAppSettingsTypeErrors = "SettingsInAppSettingsTypeErrors" // All parameters have incorrect types + // BaseAddresses file names + + case settingsBaseAddressesError = "SettingsBaseAddressesError" // Key is `baseAddressesTest` instead of `baseAddresses` + case settingsBaseAddressesTypeError = "SettingsBaseAddressesTypeError" // Type of `baseAddresses` is Int instead of BaseAddresses + case settingsBaseAddressesOperationsError = "SettingsBaseAddressesOperationsError" // Key is `operationsTest` instead of `operations` + case settingsBaseAddressesOperationsTypeError = "SettingsBaseAddressesOperationsTypeError" // Type of `operations` is Int instead of String + } final class SettingsConfigParsingTests: XCTestCase { @@ -93,6 +100,9 @@ final class SettingsConfigParsingTests: XCTestCase { XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") XCTAssertEqual(config.featureToggles?.shouldSendInAppShowError, true, "shouldSendInAppShowError must be parsed correctly") + + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertEqual(config.baseAddresses?.operations, "anonymizer-demo-api-regular.mindbox.ru", "operations must be parsed correctly") } // MARK: - Operations @@ -519,15 +529,63 @@ final class SettingsConfigParsingTests: XCTestCase { func test_SettingsConfig_withInAppSettingsTypeErrors_shouldSetAllValuesToNil() { // All parameters have incorrect types let config = try! SettingsConfig.settingsInAppSettingsTypeErrors.getConfig() - + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") - + XCTAssertNil(config.inapp, "InAppSettings must be nil") XCTAssertNil(config.inapp?.maxInappsPerSession, "maxInappsPerSession must be nil due to type error") XCTAssertNil(config.inapp?.maxInappsPerDay, "maxInappsPerDay must be nil due to type error") XCTAssertNil(config.inapp?.minIntervalBetweenShows, "minIntervalBetweenShows must be nil due to type error") } + // MARK: - BaseAddresses + + func test_SettingsConfig_withBaseAddressesError_shouldSetBaseAddressesToNil() { + // Key is `baseAddressesTest` instead of `baseAddresses` + let config = try! SettingsConfig.settingsBaseAddressesError.getConfig() + XCTAssertNil(config.baseAddresses, "BaseAddresses must be `nil` if the key `baseAddresses` is not found") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + XCTAssertNotNil(config.inapp, "InAppSettings must be successfully parsed") + XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesTypeError_shouldSetBaseAddressesToNil() { + // Type of `baseAddresses` is Int instead of BaseAddresses + let config = try! SettingsConfig.settingsBaseAddressesTypeError.getConfig() + XCTAssertNil(config.baseAddresses, "BaseAddresses must be `nil` if the type of `baseAddresses` is not a `BaseAddresses`") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + XCTAssertNotNil(config.inapp, "InAppSettings must be successfully parsed") + XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesOperationsError_shouldSetOperationsToNil() { + // Key is `operationsTest` instead of `operations` + let config = try! SettingsConfig.settingsBaseAddressesOperationsError.getConfig() + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertNil(config.baseAddresses?.operations, "operations must be `nil` if the key `operations` is not found") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesOperationsTypeError_shouldSetOperationsToNil() { + // Type of `operations` is Int instead of String + let config = try! SettingsConfig.settingsBaseAddressesOperationsTypeError.getConfig() + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertNil(config.baseAddresses?.operations, "operations must be `nil` if the type of `operations` is not a `String`") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + } + } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json new file mode 100644 index 000000000..356641a57 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddressesTest": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json new file mode 100644 index 000000000..f145b4dcf --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operationsTest": "anonymizer-demo-api-regular.mindbox.ru" + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json new file mode 100644 index 000000000..7220a111d --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": 123 + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json new file mode 100644 index 000000000..8eae125af --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json @@ -0,0 +1,29 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": 123 +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json index 333dfadae..c71f30eb9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureTogglesTest": { - "MobileSdkShouldSendInAppShowError": true + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureTogglesTest": { + "MobileSdkShouldSendInAppShowError": true + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json index 119f59394..2e49e6cd9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": false + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json index 7d8532fce..22ad2bb27 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json @@ -1,27 +1,26 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": {} } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json index f8beeba06..962e6fcc4 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": "yes" + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": "yes" + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json index 2be0abea5..5f7534e0f 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json @@ -1,26 +1,26 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": 123 + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": 123 } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json index e2e0d7432..4f9f1c8c0 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json index 2869f6137..2e84f20d9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json @@ -18,5 +18,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json index 850a1020c..b3190668f 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json index 164da9a07..33325f1cd 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json @@ -14,5 +14,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json index ee80de43f..85de3c662 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json index f06b175e7..19750a703 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json index 0ebcc88ba..7b68e495b 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json index 2a8e4cf03..7688cda6a 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json index 0f6f28c8a..1a5fd4d2d 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json @@ -20,5 +20,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json index 1588843d6..d9e492797 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json index b5d2fc609..77b7561a6 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json index e32116ea9..b9428f212 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json index 232f4c94a..6e6cde150 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json @@ -22,5 +22,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json index c03b23c2f..67893c3ad 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": true + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json index 335c80084..b30a726ed 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json index 312cc0253..02849935a 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json index 9afa8c0a8..9e98f807c 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json index d8132522c..17a8fc3df 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json index 577808a64..0aa6c7ed4 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json index 64c11af8f..56e48d587 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json @@ -21,5 +21,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json index fbe1538d5..84285bb3c 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json index b6da818a9..56ae13299 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json index 6b99a2362..f4c2f5087 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json index 9d5422fc6..e21788dc6 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json @@ -22,5 +22,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 2a18103ab..897c85893 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -108,32 +108,12 @@ struct OperationsURLRoutingTests { } } - // MARK: - Config JSON decoding (backend schema preserves `baseAddresses.operations`) - - @Test("Settings decodes baseAddresses.operations", .tags(.decoding)) - func settingsDecodesBaseAddresses() throws { - let json = """ - { - "settings": { - "baseAddresses": { "operations": "anonymizer.client.ru" }, - "ttl": { "inapps": "1.00:00:00" } - } - } - """.data(using: .utf8)! - - let response = try JSONDecoder().decode(ConfigResponse.self, from: json) - #expect(response.settings?.baseAddresses?.operations == "anonymizer.client.ru") - } - - @Test("Settings tolerates missing baseAddresses", .tags(.decoding)) - func settingsWithoutBaseAddresses() throws { - let json = """ - { "settings": { "ttl": { "inapps": "1.00:00:00" } } } - """.data(using: .utf8)! - - let response = try JSONDecoder().decode(ConfigResponse.self, from: json) - #expect(response.settings?.baseAddresses == nil) - } + // MARK: - Rollback signals from JSON config + // + // Happy-path and key/type errors live in `SettingsConfigParsingTests` + // (driven by the canonical `pkl-mobile-config` stubs). The two cases + // below stay here because they exercise the rollback channel that's + // specific to this feature and not modeled in the Pkl error stubs. @Test("Settings decodes explicit null as rollback signal", .tags(.decoding)) func settingsDecodesNullOperationsAsRollback() throws { From 8fb075e2b1c2b49874234cb9b47c83da5204097e Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:30:12 +0300 Subject: [PATCH 03/15] MOBILE-130: Centralize MBConfiguration tests and expand coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `MindboxTests/Configuration/MBConfigurationTests.swift` (Swift Testing, `.mbConfiguration` tag) — a single suite covering the full behavior of `MBConfiguration`: programmatic init validation (`domain`, `endpoint`, `operationsDomain`, UUID handling for `previousInstallationId`/`previousDeviceUUID`), defaults, plist init, Codable (legacy / new / decoder validation), and `ConfigValidation.compare` across the whole input space (identity, `nil` handling, `.rest`-affecting fields, `.shouldCreateCustomer` priority, fields that must not trigger any change). Consolidates from two sources: - Plist init coverage from `MBConfigurationTestCase.swift` (XCTest, deleted) — folded into a single parametrised section. - Seven `MBConfiguration`-shaped tests previously living in `OperationsURLRoutingTests.swift` (init validation, two `ConfigValidation.compare` cases, legacy JSON decoding). Adds `Tag.mbConfiguration` and registers the new file under a new `Configuration/` group in `Mindbox.xcodeproj/project.pbxproj`. --- Mindbox.xcodeproj/project.pbxproj | 16 +- .../Configuration/MBConfigurationTests.swift | 372 ++++++++++++++++++ MindboxTests/Extensions/Tag+Extensions.swift | 1 + MindboxTests/MBConfigurationTestCase.swift | 53 --- .../Network/OperationsURLRoutingTests.swift | 89 ----- 5 files changed, 385 insertions(+), 146 deletions(-) create mode 100644 MindboxTests/Configuration/MBConfigurationTests.swift delete mode 100644 MindboxTests/MBConfigurationTestCase.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 564f2c8ac..00671529e 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -28,7 +28,7 @@ 31A20D4E25B6EFB600AAA0A3 /* MindboxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */; }; 31EB907325C402F900368FFB /* TestConfig3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907125C402F900368FFB /* TestConfig3.plist */; }; 31EB907425C402F900368FFB /* TestConfig2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907225C402F900368FFB /* TestConfig2.plist */; }; - 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */; }; + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; 31ED2DF225C4456600301FAD /* TestConfig_Invalid_2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */; }; 31ED2DF325C4456600301FAD /* TestConfig_Invalid_1.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */; }; 31ED2DF425C4456600301FAD /* TestConfig_Invalid_3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */; }; @@ -757,7 +757,7 @@ 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxDelegate.swift; sourceTree = ""; }; 31EB907125C402F900368FFB /* TestConfig3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig3.plist; sourceTree = ""; }; 31EB907225C402F900368FFB /* TestConfig2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig2.plist; sourceTree = ""; }; - 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTestCase.swift; sourceTree = ""; }; + F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_2.plist; sourceTree = ""; }; 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; @@ -1624,7 +1624,7 @@ 84FCD3B325CA0FD300D1E574 /* Mock */, 84B625F525C98EE000AB6228 /* DI */, 84B625EE25C98A8000AB6228 /* Validators */, - 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */, + F3CD20282F600A800065392A /* Configuration */, 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */, 84DC49D525D185A600D5D758 /* Supporting Files */, 313B234025ADEA0F00A1CB72 /* Info.plist */, @@ -3526,6 +3526,14 @@ path = FeatureTogglesError; sourceTree = ""; }; + F3CD20282F600A800065392A /* Configuration */ = { + isa = PBXGroup; + children = ( + F3CD20272F600A800065392A /* MBConfigurationTests.swift */, + ); + path = Configuration; + sourceTree = ""; + }; F3BA10502F500A800065392A /* BaseAddressesError */ = { isa = PBXGroup; children = ( @@ -4709,7 +4717,7 @@ 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */, F39116EE2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift in Sources */, A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */, - 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */, + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */, F3D925AD2A1236F400135C87 /* URLSessionImageDownloaderTests.swift in Sources */, 4741DAC42E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift in Sources */, F3A8B9A02A3A52F400E9C055 /* ABTestValidatorTests.swift in Sources */, diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift new file mode 100644 index 000000000..bde35779a --- /dev/null +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -0,0 +1,372 @@ +// +// MBConfigurationTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("MBConfiguration", .tags(.mbConfiguration)) +struct MBConfigurationTests { + + private let domain = "api.mindbox.ru" + private let endpoint = "test-endpoint" + private let validUUID = "F47AC10B-58CC-4372-A567-0E02B2C3D479" + + // MARK: - Init: domain validation + + @Test("Valid domain is accepted") + func validDomainAccepted() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.domain == domain) + } + + @Test("Empty domain throws") + func emptyDomainThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "") + } + } + + @Test("Domain with whitespace throws") + func domainWithWhitespaceThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "api mindbox ru") + } + } + + @Test("Domain with embedded scheme throws") + func domainWithSchemeThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") + } + } + + // MARK: - Init: endpoint validation + + @Test("Empty endpoint throws") + func emptyEndpointThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: "", domain: domain) + } + } + + // MARK: - Init: operationsDomain validation + + @Test("nil operationsDomain stored as nil") + func nilOperationsDomainStoredAsNil() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.operationsDomain == nil) + } + + @Test("Valid operationsDomain stored as-is") + func validOperationsDomainStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "anonymizer.client.ru" + ) + #expect(config.operationsDomain == "anonymizer.client.ru") + } + + @Test("Empty operationsDomain treated as nil (not throw)") + func emptyOperationsDomainTreatedAsNil() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "" + ) + #expect(config.operationsDomain == nil) + } + + @Test("Invalid operationsDomain throws") + func invalidOperationsDomainThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "not a host with spaces" + ) + } + } + + // MARK: - Init: previousInstallationId / previousDeviceUUID UUID handling + + @Test("Valid previousInstallationId UUID is stored") + func validPreviousInstallationIdStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: validUUID + ) + #expect(config.previousInstallationId == validUUID) + } + + @Test("Invalid previousInstallationId is silently coerced to empty string") + func invalidPreviousInstallationIdCoercedToEmpty() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: "not-a-uuid" + ) + #expect(config.previousInstallationId == "") + } + + @Test("Empty previousInstallationId stays nil") + func emptyPreviousInstallationIdStaysNil() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: "" + ) + #expect(config.previousInstallationId == nil) + } + + @Test("Valid previousDeviceUUID is stored") + func validPreviousDeviceUUIDStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousDeviceUUID: validUUID + ) + #expect(config.previousDeviceUUID == validUUID) + } + + @Test("Invalid previousDeviceUUID is silently coerced to empty string") + func invalidPreviousDeviceUUIDCoercedToEmpty() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousDeviceUUID: "not-a-uuid" + ) + #expect(config.previousDeviceUUID == "") + } + + // MARK: - Init: defaults + + @Test("Default values match documented public API") + func defaultValues() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.subscribeCustomerIfCreated == false) + #expect(config.shouldCreateCustomer == true) + #expect(config.imageLoadingMaxTimeInSeconds == nil) + #expect(config.previousInstallationId == nil) + #expect(config.previousDeviceUUID == nil) + #expect(config.operationsDomain == nil) + } + + // MARK: - Init: plist + + @Test("Plist init succeeds for valid configurations", .tags(.decoding)) + func plistInitSucceedsForValidConfigs() throws { + // TestConfig1/2/3 — full valid configurations. + // TestConfig_Invalid_3/4 — valid despite the filename (only previousIDs / domain + // edges are checked at the type level, not the file). + for plist in ["TestConfig1", "TestConfig2", "TestConfig3", "TestConfig_Invalid_3", "TestConfig_Invalid_4"] { + #expect(throws: Never.self) { try MBConfiguration(plistName: plist) } + } + } + + @Test("Plist init throws on empty domain or endpoint", .tags(.decoding)) + func plistInitThrowsOnInvalid() { + // TestConfig_Invalid_1 — empty domain. TestConfig_Invalid_2 — empty endpoint. + for plist in ["TestConfig_Invalid_1", "TestConfig_Invalid_2"] { + #expect(throws: (any Error).self) { try MBConfiguration(plistName: plist) } + } + } + + @Test("Plist init throws on missing file") + func plistInitThrowsOnMissingFile() { + #expect(throws: (any Error).self) { + try MBConfiguration(plistName: "definitely-does-not-exist") + } + } + + // MARK: - Codable + + @Test("Decodes legacy JSON without operationsDomain key", .tags(.decoding)) + func decodesLegacyJSONWithoutOperationsDomain() throws { + let legacyJSON = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "subscribeCustomerIfCreated": false, + "shouldCreateCustomer": true, + "uuidDebugEnabled": true + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) + #expect(config.endpoint == "app-IOS") + #expect(config.domain == "api.mindbox.ru") + #expect(config.operationsDomain == nil) + } + + @Test("Decodes JSON with operationsDomain", .tags(.decoding)) + func decodesJSONWithOperationsDomain() throws { + let json = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "operationsDomain": "anonymizer.client.ru" + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: json) + #expect(config.operationsDomain == "anonymizer.client.ru") + } + + @Test("Decoder applies the same validation as the programmatic init", .tags(.decoding)) + func decoderEnforcesValidation() { + let invalid = """ + { "endpoint": "", "domain": "api.mindbox.ru" } + """.data(using: .utf8)! + + #expect(throws: (any Error).self) { + _ = try JSONDecoder().decode(MBConfiguration.self, from: invalid) + } + } + + // MARK: - ConfigValidation.compare — identity / nil handling + + @Test("Identical configs → none") + func identicalConfigsReturnNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("Both sides nil → none") + func bothNilReturnNone() { + var validation = ConfigValidation() + validation.compare(nil, nil) + #expect(validation.changedState == .none) + } + + @Test("nil vs configured → rest (first init counts as REST change)") + func nilToConfiguredReturnsRest() throws { + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(nil, rhs) + #expect(validation.changedState == .rest) + } + + @Test("configured vs nil → rest") + func configuredToNilReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, nil) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — REST-affecting fields + + @Test("Domain change → rest") + func domainChangeReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: "a.mindbox.ru") + let rhs = try MBConfiguration(endpoint: endpoint, domain: "b.mindbox.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + @Test("Endpoint change → rest") + func endpointChangeReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: "endpoint-A", domain: domain) + let rhs = try MBConfiguration(endpoint: "endpoint-B", domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + @Test("Domain and endpoint both change → rest (single classification)") + func bothRestFieldsChangeReturnRest() throws { + let lhs = try MBConfiguration(endpoint: "endpoint-A", domain: "a.mindbox.ru") + let rhs = try MBConfiguration(endpoint: "endpoint-B", domain: "b.mindbox.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — shouldCreateCustomer + + @Test("shouldCreateCustomer change → shouldCreateCustomer") + func shouldCreateCustomerChangeReturnsShouldCreateCustomer() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, shouldCreateCustomer: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, shouldCreateCustomer: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .shouldCreateCustomer) + } + + @Test("rest change wins over shouldCreateCustomer change (priority)") + func restWinsOverShouldCreateCustomer() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: "a.mindbox.ru", shouldCreateCustomer: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: "b.mindbox.ru", shouldCreateCustomer: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — fields that must NOT trigger any change + + @Test("operationsDomain change → none (new value applies without re-install)") + func operationsDomainChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, operationsDomain: "old.client.ru") + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, operationsDomain: "new.client.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("subscribeCustomerIfCreated change → none") + func subscribeCustomerIfCreatedChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, subscribeCustomerIfCreated: false) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, subscribeCustomerIfCreated: true) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("previousInstallationId change → none") + func previousInstallationIdChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, previousInstallationId: validUUID) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("previousDeviceUUID change → none") + func previousDeviceUUIDChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, previousDeviceUUID: validUUID) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("imageLoadingMaxTimeInSeconds change → none") + func imageLoadingMaxTimeInSecondsChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, imageLoadingMaxTimeInSeconds: 5) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, imageLoadingMaxTimeInSeconds: 10) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("uuidDebugEnabled change → none") + func uuidDebugEnabledChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, uuidDebugEnabled: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, uuidDebugEnabled: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } +} diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 0cd6600d6..f9381ded6 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -26,4 +26,5 @@ extension Tag { @Tag static var webView: Self @Tag static var trackVisit: Self @Tag static var operationsRouting: Self + @Tag static var mbConfiguration: Self } diff --git a/MindboxTests/MBConfigurationTestCase.swift b/MindboxTests/MBConfigurationTestCase.swift deleted file mode 100644 index b3f88629f..000000000 --- a/MindboxTests/MBConfigurationTestCase.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// MBConfigurationTest.swift -// MindboxTests -// -// Created by Mikhail Barilov on 29.01.2021. -// Copyright © 2021 Mindbox. All rights reserved. -// - -import XCTest -@testable import Mindbox - -class MBConfigurationTestCase: XCTestCase { - // Invalid - let emptyDomainFile = "TestConfig_Invalid_1" - let emptyEndpointFile = "TestConfig_Invalid_2" - // Valid - let emptyUUIDFile = "TestConfig_Invalid_3" - let emptyIDDomainFile = "TestConfig_Invalid_4" - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - func test_MBConfiguration_should_not_throw() throws { - try [ - emptyUUIDFile, - emptyIDDomainFile - ].forEach { file in - XCTAssertNoThrow(try MBConfiguration(plistName: file), "") - } - } - - func test_MBConfiguration_should_throw() throws { - try [ - emptyDomainFile, - emptyEndpointFile - ].forEach { file in - XCTAssertThrowsError(try MBConfiguration(plistName: file), "") { error in - if let localizedError = error as? LocalizedError { - XCTAssertNotNil(localizedError.errorDescription) - XCTAssertNotNil(localizedError.failureReason) - } - } - } - - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig1")) - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig2")) - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig3")) - XCTAssertNil(try? MBConfiguration(plistName: "file_that_|never_exist№%:,.;()(;.,:%№")) - } -} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 897c85893..173065189 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -69,45 +69,6 @@ struct OperationsURLRoutingTests { #expect(url?.query?.contains("endpointId=test-endpoint") == true) } - // MARK: - MBConfiguration validation - - @Test("MBConfiguration accepts nil operationsDomain (backwards compatible)") - func configAcceptsNilOperationsDomain() throws { - let config = try MBConfiguration(endpoint: "e", domain: domain) - #expect(config.operationsDomain == nil) - } - - @Test("MBConfiguration accepts valid operationsDomain") - func configAcceptsValidOperationsDomain() throws { - let config = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: opsHost - ) - #expect(config.operationsDomain == opsHost) - } - - @Test("MBConfiguration treats empty operationsDomain as nil") - func configTreatsEmptyOperationsDomainAsNil() throws { - let config = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "" - ) - #expect(config.operationsDomain == nil) - } - - @Test("MBConfiguration rejects invalid operationsDomain") - func configRejectsInvalidOperationsDomain() { - #expect(throws: MindboxError.self) { - _ = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "not a host with spaces" - ) - } - } - // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` @@ -136,36 +97,6 @@ struct OperationsURLRoutingTests { #expect(response.settings?.baseAddresses?.operations == "") } - // MARK: - ConfigValidation.compare - - @Test("ConfigValidation does NOT flag operationsDomain change — new value applies without re-install") - func configValidationIgnoresOperationsDomainChange() throws { - let lhs = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "old.client.ru" - ) - let rhs = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "new.client.ru" - ) - var validation = ConfigValidation() - validation.compare(lhs, rhs) - #expect(validation.changedState == .none) - } - - @Test("ConfigValidation still flags domain change as REST (operationsDomain unaffected)") - func configValidationDetectsDomainChange() throws { - let lhs = try MBConfiguration(endpoint: "e", domain: "a.mindbox.ru", - operationsDomain: opsHost) - let rhs = try MBConfiguration(endpoint: "e", domain: "b.mindbox.ru", - operationsDomain: opsHost) - var validation = ConfigValidation() - validation.compare(lhs, rhs) - #expect(validation.changedState == .rest) - } - // MARK: - Priority resolution (MBNetworkFetcher) @Test("Priority — JSON wins when both JSON and init are set") @@ -275,26 +206,6 @@ struct OperationsURLRoutingTests { #expect(storage.operationsDomainFromConfig == nil) } - // MARK: - Backwards compatibility - - @Test("MBConfiguration decodes legacy JSON without operationsDomain key", .tags(.decoding)) - func decodesLegacyConfigWithoutOperationsDomain() throws { - let legacyJSON = """ - { - "endpoint": "app-IOS", - "domain": "api.mindbox.ru", - "subscribeCustomerIfCreated": false, - "shouldCreateCustomer": true, - "uuidDebugEnabled": true - } - """.data(using: .utf8)! - - let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) - #expect(config.endpoint == "app-IOS") - #expect(config.domain == "api.mindbox.ru") - #expect(config.operationsDomain == nil) - } - // MARK: - Helpers private static func makeEventWrapper( From 82c667bf6d724d32871595d34be19e0dd4c024e4 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:42:46 +0300 Subject: [PATCH 04/15] MOBILE-130: Drop dead SDKLogsRoute and use the live path in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SDKLogsRoute` had no references in production code: SDK logs flow through `SDKLogsManager.sendLogs` → `EventRepository.send` → `EventRoute.asyncEvent` (`MBEventRepository.makeRoute` for `.sdkLogs`). Removes the type from `SDKLogsRequest.swift`. `SDKLogsRequest` (the Codable body) stays — it is the request payload encoded into `Event.body`. Updates `OperationsURLRoutingTests` to assert routing for `.sdkLogs` through the actual `EventRoute.asyncEvent` path. The dedicated `sdkLogsRouteUsesOperationsDomain` test is removed (subsumed by the extended `eventRoutesUseOperationsDomain` and `noOperationsDomainFallsBackToDomain` cases). --- Mindbox/MindboxLogger/SDKLogsRequest.swift | 17 ----------------- .../Network/OperationsURLRoutingTests.swift | 12 +++++------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/Mindbox/MindboxLogger/SDKLogsRequest.swift b/Mindbox/MindboxLogger/SDKLogsRequest.swift index a952d9930..7530eaf08 100644 --- a/Mindbox/MindboxLogger/SDKLogsRequest.swift +++ b/Mindbox/MindboxLogger/SDKLogsRequest.swift @@ -12,20 +12,3 @@ struct SDKLogsRequest: Codable { let requestId: String let content: [String] } - -struct SDKLogsRoute: Route { - var method: HTTPMethod { .post } - var path: String { "/v3/operations/async/MobileSdk.Logs" } - var headers: HTTPHeaders? { nil } - var queryParameters: QueryParameters { .init() } - var body: Data? - var baseURLKind: RouteBaseURL { .operations } - - func makeBasicQueryParameters(with wrapper: EventWrapper) -> QueryParameters { - ["transactionId": wrapper.event.transactionId, - "deviceUUID": wrapper.deviceUUID, - "dateTimeOffset": wrapper.event.dateTimeOffset, - "operation": wrapper.event.type.rawValue, - "endpointId": wrapper.endpoint] - } -} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 173065189..368fa9d89 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -29,13 +29,10 @@ struct OperationsURLRoutingTests { let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) #expect(try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url?.host == opsHost) - } - @Test("SDKLogsRoute uses operationsDomain when configured") - func sdkLogsRouteUsesOperationsDomain() throws { - let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) - let url = try builder.asURLRequest(route: SDKLogsRoute()).url - #expect(url?.host == opsHost) + // SDK logs flow through the same `EventRoute.asyncEvent` (see `MBEventRepository.makeRoute`). + let logsWrapper = Self.makeEventWrapper(.sdkLogs) + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(logsWrapper)).url?.host == opsHost) } @Test("Config and geo routes always use domain") @@ -51,10 +48,11 @@ struct OperationsURLRoutingTests { func noOperationsDomainFallsBackToDomain() throws { let builder = URLRequestBuilder(domain: domain) let wrapper = Self.makeEventWrapper(.installed) + let logsWrapper = Self.makeEventWrapper(.sdkLogs) #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == domain) #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == domain) - #expect(try builder.asURLRequest(route: SDKLogsRoute()).url?.host == domain) + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(logsWrapper)).url?.host == domain) #expect(try builder.asURLRequest(route: FetchInAppGeoRoute()).url?.host == domain) } From 372319540cbab15270b1c403ff37342915023b9b Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:03:44 +0300 Subject: [PATCH 05/15] MOBILE-130: Accept domain inputs with optional scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MBConfiguration.domain` and `MBConfiguration.operationsDomain` now accept `host`, `https://host`, and `http://host` (with or without trailing slash). When a scheme is present in the input, requests are built with that scheme; otherwise `https://` is used as before. Mirrors the Android SDK's `SdkValidation.extractHost` / `toBaseUrl` pair, so both platforms accept the same inputs (e.g. a value pasted straight from the dashboard URL). Adds `Mindbox/Network/Helpers/HostNormalizer.swift`: - `extractHost` — strips scheme (case-insensitive), whitespace, and trailing slashes. - `toBaseURLString` — preserves an existing scheme or prepends `https://`. - `isValidHost` — runs the extracted host through `URLValidator`. Encapsulates the awkward `URL(string: "https://" + host)` + `URLValidator` pattern previously duplicated across three call sites. `URLRequestBuilder` builds the base URL via `HostNormalizer.toBaseURLString(...)` before adding `path` / `queryItems`, so the resolved scheme propagates to every route. Same value also flows through `OperationsDomainConfigPolicy` when validating the operations host coming from the JSON config. Tests: - `HostNormalizerTests` (Swift Testing) — extract / base-URL / validation across scheme, case, trailing slash, whitespace. - `MBConfigurationTests` — accepts `domain` / `operationsDomain` with `https://`, `http://`, trailing slash. Replaces the previous "scheme throws" invariant. - `OperationsURLRoutingTests` — bare host → https default, `https://` / `http://` preserved end-to-end, trailing slash stripped before path append. Backwards compatible: integrators who pass a bare host get exactly the same URLs as before. --- Mindbox.xcodeproj/project.pbxproj | 8 ++ .../OperationsDomainConfigPolicy.swift | 3 +- Mindbox/MBConfiguration.swift | 4 +- Mindbox/Network/Helpers/HostNormalizer.swift | 48 +++++++++ .../Network/Helpers/URLRequestBuilder.swift | 5 +- .../Configuration/MBConfigurationTests.swift | 31 +++++- .../Network/HostNormalizerTests.swift | 102 ++++++++++++++++++ .../Network/OperationsURLRoutingTests.swift | 52 +++++++++ 8 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 Mindbox/Network/Helpers/HostNormalizer.swift create mode 100644 MindboxTests/Network/HostNormalizerTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 00671529e..11bf43707 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 31EB907325C402F900368FFB /* TestConfig3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907125C402F900368FFB /* TestConfig3.plist */; }; 31EB907425C402F900368FFB /* TestConfig2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907225C402F900368FFB /* TestConfig2.plist */; }; F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; 31ED2DF225C4456600301FAD /* TestConfig_Invalid_2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */; }; 31ED2DF325C4456600301FAD /* TestConfig_Invalid_1.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */; }; 31ED2DF425C4456600301FAD /* TestConfig_Invalid_3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */; }; @@ -57,6 +58,7 @@ 3333C1B22681D42000B60D84 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B12681D42000B60D84 /* Payload.swift */; }; 3333C1B42681D43C00B60D84 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B32681D43C00B60D84 /* ImageFormat.swift */; }; 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */; }; + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; 3333C1E12681EA4D00B60D84 /* NotificationsPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */; }; 3333D7BE265E56F2004279B0 /* OperationResponseType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */; }; 3337E6A3265FAB39006949EB /* BaseResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3337E6A2265FAB39006949EB /* BaseResponse.swift */; }; @@ -758,6 +760,7 @@ 31EB907125C402F900368FFB /* TestConfig3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig3.plist; sourceTree = ""; }; 31EB907225C402F900368FFB /* TestConfig2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig2.plist; sourceTree = ""; }; F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_2.plist; sourceTree = ""; }; 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; @@ -789,6 +792,7 @@ 3333C1B12681D42000B60D84 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = ""; }; 3333C1B32681D43C00B60D84 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestBuilder.swift; sourceTree = ""; }; + F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationsPayloads.swift; sourceTree = ""; }; 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationResponseType.swift; sourceTree = ""; }; 3337E6A2265FAB39006949EB /* BaseResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseResponse.swift; sourceTree = ""; }; @@ -2272,6 +2276,7 @@ children = ( 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, ); name = Network; path = Network; @@ -2390,6 +2395,7 @@ isa = PBXGroup; children = ( 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */, + F3CD202A2F600A800065392A /* HostNormalizer.swift */, 84EAEDFB25C8B18B00726063 /* DeviceModelHelper.swift */, ); path = Helpers; @@ -4425,6 +4431,7 @@ 9B24FAB528C751E400F10B5D /* InAppImagesStorage.swift in Sources */, F3A8B9A32A3A6E6900E9C055 /* SdkVersionModel.swift in Sources */, 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */, + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */, F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */, F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */, 334F3AF5264C199900A6AC00 /* CodableDictionary.swift in Sources */, @@ -4766,6 +4773,7 @@ 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */, 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index bec7e4616..33c3a8563 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -26,8 +26,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard let url = URL(string: "https://" + value), - URLValidator(url: url).evaluate() else { + guard HostNormalizer.isValidHost(value) else { return .keep } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 1091c6534..41322d1cf 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -51,7 +51,7 @@ public struct MBConfiguration: Codable { self.endpoint = endpoint self.domain = domain - guard let url = URL(string: "https://" + domain), URLValidator(url: url).evaluate() else { + guard HostNormalizer.isValidHost(domain) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid domain. Domain is unreachable. [Domain]: \(domain)")) Logger.error(error.asLoggerError()) throw error @@ -64,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard let url = URL(string: "https://" + operationsDomain), URLValidator(url: url).evaluate() else { + guard HostNormalizer.isValidHost(operationsDomain) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)")) Logger.error(error.asLoggerError()) throw error diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift new file mode 100644 index 000000000..9eaf82b1d --- /dev/null +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -0,0 +1,48 @@ +// +// HostNormalizer.swift +// Mindbox +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Scheme-aware normalization for `domain` / `operationsDomain` inputs. +/// Accepts `host`, `https://host`, `http://host`, with or without trailing slash. +enum HostNormalizer { + + /// Strips scheme (case-insensitive), whitespace, and trailing slashes. + static func extractHost(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if value.lowercased().hasPrefix("https://") { + value = String(value.dropFirst("https://".count)) + } else if value.lowercased().hasPrefix("http://") { + value = String(value.dropFirst("http://".count)) + } + while value.hasSuffix("/") { + value.removeLast() + } + return value + } + + /// Preserves an existing scheme, otherwise prepends `https://`. + static func toBaseURLString(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + while value.hasSuffix("/") { value.removeLast() } + let lower = value.lowercased() + if lower.hasPrefix("http://") || lower.hasPrefix("https://") { + return value + } + return "https://" + value + } + + /// Validates the extracted host. The `https://` prefix is required by + /// `URLValidator`'s full-URL regex — to be folded into URLValidator on rewrite. + static func isValidHost(_ raw: String) -> Bool { + let host = extractHost(raw) + guard !host.isEmpty, + let url = URL(string: "https://" + host) else { return false } + return URLValidator(url: url).evaluate() + } +} diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 86cd71810..a73aac867 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -38,9 +38,8 @@ struct URLRequestBuilder { } private func makeURLComponents(for route: Route) -> URLComponents { - var components = URLComponents() - components.scheme = "https" - components.host = resolvedHost(for: route) + let baseURL = HostNormalizer.toBaseURLString(resolvedHost(for: route)) + var components = URLComponents(string: baseURL) ?? URLComponents() components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index bde35779a..76320010e 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -39,11 +39,32 @@ struct MBConfigurationTests { } } - @Test("Domain with embedded scheme throws") - func domainWithSchemeThrows() { - #expect(throws: MindboxError.self) { - _ = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") - } + @Test("Domain accepts https:// prefix") + func domainAcceptsHttpsPrefix() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") + #expect(config.domain == "https://api.mindbox.ru") + } + + @Test("Domain accepts http:// prefix") + func domainAcceptsHttpPrefix() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "http://proxy.example.com") + #expect(config.domain == "http://proxy.example.com") + } + + @Test("Domain accepts trailing slash") + func domainAcceptsTrailingSlash() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "api.mindbox.ru/") + #expect(config.domain == "api.mindbox.ru/") + } + + @Test("operationsDomain accepts https:// prefix") + func operationsDomainAcceptsHttpsPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "https://anonymizer.client.ru" + ) + #expect(config.operationsDomain == "https://anonymizer.client.ru") } // MARK: - Init: endpoint validation diff --git a/MindboxTests/Network/HostNormalizerTests.swift b/MindboxTests/Network/HostNormalizerTests.swift new file mode 100644 index 000000000..341656a8d --- /dev/null +++ b/MindboxTests/Network/HostNormalizerTests.swift @@ -0,0 +1,102 @@ +// +// HostNormalizerTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("HostNormalizer") +struct HostNormalizerTests { + + // MARK: - extractHost + + @Test("Bare host is returned unchanged") + func bareHost() { + #expect(HostNormalizer.extractHost("api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("https:// prefix is stripped") + func stripsHttps() { + #expect(HostNormalizer.extractHost("https://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("http:// prefix is stripped") + func stripsHttp() { + #expect(HostNormalizer.extractHost("http://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("Scheme stripping is case-insensitive") + func schemeCaseInsensitive() { + #expect(HostNormalizer.extractHost("HTTPS://api.mindbox.ru") == "api.mindbox.ru") + #expect(HostNormalizer.extractHost("HtTp://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("Trailing slashes are removed") + func stripsTrailingSlashes() { + #expect(HostNormalizer.extractHost("api.mindbox.ru/") == "api.mindbox.ru") + #expect(HostNormalizer.extractHost("api.mindbox.ru///") == "api.mindbox.ru") + } + + @Test("Whitespace is trimmed") + func trimsWhitespace() { + #expect(HostNormalizer.extractHost(" api.mindbox.ru ") == "api.mindbox.ru") + } + + @Test("Combined scheme + trailing slash + whitespace") + func combinedNormalization() { + #expect(HostNormalizer.extractHost(" https://api.mindbox.ru/ ") == "api.mindbox.ru") + } + + // MARK: - toBaseURLString + + @Test("Bare host gets https:// prepended") + func bareHostGetsHttps() { + #expect(HostNormalizer.toBaseURLString("api.mindbox.ru") == "https://api.mindbox.ru") + } + + @Test("https:// is preserved") + func httpsPreserved() { + #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru") == "https://api.mindbox.ru") + } + + @Test("http:// is preserved") + func httpPreserved() { + #expect(HostNormalizer.toBaseURLString("http://proxy.example.com") == "http://proxy.example.com") + } + + @Test("Trailing slash is stripped from base URL") + func baseURLStripsTrailingSlash() { + #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru/") == "https://api.mindbox.ru") + #expect(HostNormalizer.toBaseURLString("api.mindbox.ru/") == "https://api.mindbox.ru") + } + + // MARK: - isValidHost + + @Test("Valid bare host passes") + func validBareHostPasses() { + #expect(HostNormalizer.isValidHost("api.mindbox.ru")) + } + + @Test("Valid host with https:// prefix passes") + func validHostWithSchemePasses() { + #expect(HostNormalizer.isValidHost("https://api.mindbox.ru")) + #expect(HostNormalizer.isValidHost("http://proxy.example.com")) + } + + @Test("Empty input fails") + func emptyHostFails() { + #expect(!HostNormalizer.isValidHost("")) + #expect(!HostNormalizer.isValidHost(" ")) + #expect(!HostNormalizer.isValidHost("https://")) + } + + @Test("Whitespace inside host fails") + func whitespaceInsideHostFails() { + #expect(!HostNormalizer.isValidHost("api mindbox ru")) + } +} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 368fa9d89..3ce1b119c 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -67,6 +67,58 @@ struct OperationsURLRoutingTests { #expect(url?.query?.contains("endpointId=test-endpoint") == true) } + // MARK: - Scheme handling (host-with-scheme passthrough) + + @Test("Bare host gets default https:// scheme") + func bareHostUsesHttps() throws { + let builder = URLRequestBuilder(domain: "api.mindbox.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Explicit https:// in domain is preserved") + func explicitHttpsPreserved() throws { + let builder = URLRequestBuilder(domain: "https://api.mindbox.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Explicit http:// in domain is preserved (proxy/staging case)") + func explicitHttpPreserved() throws { + let builder = URLRequestBuilder(domain: "http://proxy.example.com") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "http") + #expect(url?.host == "proxy.example.com") + } + + @Test("Explicit https:// in operationsDomain is preserved") + func explicitHttpsInOperationsDomainPreserved() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + } + + @Test("Trailing slash in host input is stripped before path append") + func trailingSlashStripped() throws { + let builder = URLRequestBuilder(domain: "api.mindbox.ru/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/v3/operations/async") + #expect(url?.host == "api.mindbox.ru") + } + // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` From d2a77e59ccc7f9cee2255c56136cd898a908bb65 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:24:56 +0300 Subject: [PATCH 06/15] MOBILE-130: Rewrite URLValidator as a structural host validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old full-URL regex (hardcoded TLD list missing `.app`/`.dev`/`.io`, `&` HTML-escape bug, per-call regex compile via `try!`) with an RFC 1123-style host validator. New TLDs are accepted automatically by structure — analogous to Android's `PatternsCompat.DOMAIN_NAME`. API: `URLValidator.isValidHost(_ host: String) -> Bool` (static, no instance). Drops the previous `URLValidator(url:).evaluate()` shape; callers (`MBConfiguration`, `OperationsDomainConfigPolicy`) are updated. Implementation is plain Swift (no regex, no `try!`): - Each label: 1..63 chars, alnum + hyphen, hyphen not at edges. - Hosts joined by `.`; single-label (`localhost`) and IPv4 literals accepted (digits-only labels are valid alnum). - Total length capped at 253 (RFC 1035), constants explicit. - ASCII-only contract: punycode (`xn--…`) passes, Unicode literals don't (callers must convert IDN to ACE). Removes the thin `HostNormalizer.isValidHost` wrapper — it was a trivial composition of `extractHost` + validation that didn't belong in a normalizer. Call sites now spell out the chain explicitly: `URLValidator.isValidHost(HostNormalizer.extractHost(value))`. Tests: - New `URLValidatorTests` (Swift Testing) with 22 cases covering passing inputs (modern TLDs, localhost, IPv4, hyphenated and single-char labels, mixed case, punycode, max label/host length boundaries) and failing inputs (empty, whitespace, underscore, edge-hyphen, empty labels, embedded scheme, path/query, special characters, Unicode literals, length overflow). - Removed `testURLValidator` from `ValidatorsTestCase` — the full-URL contract no longer applies. - Removed redundant `isValidHost` tests from `HostNormalizerTests` (covered by `URLValidatorTests` now). --- Mindbox.xcodeproj/project.pbxproj | 4 + .../OperationsDomainConfigPolicy.swift | 2 +- Mindbox/MBConfiguration.swift | 4 +- Mindbox/Network/Helpers/HostNormalizer.swift | 9 - Mindbox/Validators/URLValidator.swift | 40 +++-- .../Network/HostNormalizerTests.swift | 25 --- .../Validators/URLValidatorTests.swift | 159 ++++++++++++++++++ .../Validators/ValidatorsTestCase.swift | 24 --- 8 files changed, 191 insertions(+), 76 deletions(-) create mode 100644 MindboxTests/Validators/URLValidatorTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 11bf43707..026c0f197 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -313,6 +313,7 @@ 84B625E425C988FA00AB6228 /* URLValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E325C988FA00AB6228 /* URLValidator.swift */; }; 84B625E925C989C100AB6228 /* UDIDValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E825C989C100AB6228 /* UDIDValidator.swift */; }; 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */; }; + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202E2F600A800065392A /* URLValidatorTests.swift */; }; 84BAEF8225D54919002E8A26 /* BodyDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */; }; 84C65E5E25D4FBA3008996FA /* MobileApplicationInstalled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */; }; 84C65E6425D4FBBB008996FA /* MobileApplicationInfoUpdated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */; }; @@ -1039,6 +1040,7 @@ 84B625E325C988FA00AB6228 /* URLValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidator.swift; sourceTree = ""; }; 84B625E825C989C100AB6228 /* UDIDValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDIDValidator.swift; sourceTree = ""; }; 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatorsTestCase.swift; sourceTree = ""; }; + F3CD202E2F600A800065392A /* URLValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidatorTests.swift; sourceTree = ""; }; 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyDecoder.swift; sourceTree = ""; }; 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInstalled.swift; sourceTree = ""; }; 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInfoUpdated.swift; sourceTree = ""; }; @@ -2451,6 +2453,7 @@ F35E0C4D2DF0535E00E8A768 /* InAppTrackingServiceTests.swift */, F3A961D52DE9C5220016D5D3 /* InAppPresentationValidatorTests.swift */, 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */, + F3CD202E2F600A800065392A /* URLValidatorTests.swift */, F3A8B9972A3A421C00E9C055 /* SDKVersionValidatorTests.swift */, F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */, ); @@ -4676,6 +4679,7 @@ 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */, 47A4FA782E73741700569870 /* LoggerDatabaseLoaderTests.swift in Sources */, 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */, + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */, 4741425E2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift in Sources */, 847F580325C88BBF00147A9A /* HTTPMethod.swift in Sources */, F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */, diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 33c3a8563..c9fbbcc60 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -26,7 +26,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard HostNormalizer.isValidHost(value) else { + guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { return .keep } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 41322d1cf..6933238fe 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -51,7 +51,7 @@ public struct MBConfiguration: Codable { self.endpoint = endpoint self.domain = domain - guard HostNormalizer.isValidHost(domain) else { + guard URLValidator.isValidHost(HostNormalizer.extractHost(domain)) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid domain. Domain is unreachable. [Domain]: \(domain)")) Logger.error(error.asLoggerError()) throw error @@ -64,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard HostNormalizer.isValidHost(operationsDomain) else { + guard URLValidator.isValidHost(HostNormalizer.extractHost(operationsDomain)) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)")) Logger.error(error.asLoggerError()) throw error diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index 9eaf82b1d..a047cbfe1 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -36,13 +36,4 @@ enum HostNormalizer { } return "https://" + value } - - /// Validates the extracted host. The `https://` prefix is required by - /// `URLValidator`'s full-URL regex — to be folded into URLValidator on rewrite. - static func isValidHost(_ raw: String) -> Bool { - let host = extractHost(raw) - guard !host.isEmpty, - let url = URL(string: "https://" + host) else { return false } - return URLValidator(url: url).evaluate() - } } diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index cab0b22c9..8f2e3f9c9 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -8,26 +8,36 @@ import Foundation -// FIXME: Rewrite this struct in the future +/// Validates a bare hostname (e.g. `api.mindbox.ru`, `localhost`, `192.168.1.1`) +/// using RFC 1123 label structure. No TLD allow-list — new TLDs (`.app`, `.dev`, …) +/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`. +enum URLValidator { -struct URLValidator { + /// RFC 1035: full hostname max 253 chars. + private static let maxHostLength = 253 - let url: URL + /// RFC 1035: each label 1..63 chars. + private static let maxLabelLength = 63 - // swiftlint:disable:next line_length - let urlPattern = "^(http|https|ftp)\\://([a-zA-Z0-9\\.\\-]+(\\:[a-zA-Z0-9\\.&%\\$\\-]+)*@)*((25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[0-9])|localhost|([a-zA-Z0-9\\-]+\\.)*[a-zA-Z0-9\\-]+\\.(com|cloud|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|tech|[a-zA-Z]{2}))(\\:[0-9]+)*(/($|[a-zA-Z0-9\\.\\,\\?\\'\\\\\\+&%\\$#\\=~_\\-]+))*$" + static func isValidHost(_ host: String) -> Bool { + guard !host.isEmpty, host.count <= maxHostLength else { return false } + return host + .split(separator: ".", omittingEmptySubsequences: false) + .allSatisfy(isValidLabel) + } - func evaluate() -> Bool { - return matches(string: url.absoluteString, pattern: urlPattern) + private static func isValidLabel(_ label: Substring) -> Bool { + guard (1...maxLabelLength).contains(label.count), + label.first != "-", + label.last != "-" + else { return false } + return label.unicodeScalars.allSatisfy(isAlnumOrHyphen) } - private func matches(string: String, pattern: String) -> Bool { - let regex = try! NSRegularExpression( // swiftlint:disable:this force_try - pattern: pattern, - options: [.caseInsensitive]) - return regex.firstMatch( - in: string, - options: [], - range: NSRange(location: 0, length: string.utf16.count)) != nil + private static func isAlnumOrHyphen(_ scalar: Unicode.Scalar) -> Bool { + ("a"..."z").contains(scalar) + || ("A"..."Z").contains(scalar) + || ("0"..."9").contains(scalar) + || scalar == "-" } } diff --git a/MindboxTests/Network/HostNormalizerTests.swift b/MindboxTests/Network/HostNormalizerTests.swift index 341656a8d..a427bf848 100644 --- a/MindboxTests/Network/HostNormalizerTests.swift +++ b/MindboxTests/Network/HostNormalizerTests.swift @@ -74,29 +74,4 @@ struct HostNormalizerTests { #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru/") == "https://api.mindbox.ru") #expect(HostNormalizer.toBaseURLString("api.mindbox.ru/") == "https://api.mindbox.ru") } - - // MARK: - isValidHost - - @Test("Valid bare host passes") - func validBareHostPasses() { - #expect(HostNormalizer.isValidHost("api.mindbox.ru")) - } - - @Test("Valid host with https:// prefix passes") - func validHostWithSchemePasses() { - #expect(HostNormalizer.isValidHost("https://api.mindbox.ru")) - #expect(HostNormalizer.isValidHost("http://proxy.example.com")) - } - - @Test("Empty input fails") - func emptyHostFails() { - #expect(!HostNormalizer.isValidHost("")) - #expect(!HostNormalizer.isValidHost(" ")) - #expect(!HostNormalizer.isValidHost("https://")) - } - - @Test("Whitespace inside host fails") - func whitespaceInsideHostFails() { - #expect(!HostNormalizer.isValidHost("api mindbox ru")) - } } diff --git a/MindboxTests/Validators/URLValidatorTests.swift b/MindboxTests/Validators/URLValidatorTests.swift new file mode 100644 index 000000000..e5d294d60 --- /dev/null +++ b/MindboxTests/Validators/URLValidatorTests.swift @@ -0,0 +1,159 @@ +// +// URLValidatorTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("URLValidator.isValidHost") +struct URLValidatorTests { + + @Test("Common multi-label hosts pass") + func multiLabelHosts() { + #expect(URLValidator.isValidHost("api.mindbox.ru")) + #expect(URLValidator.isValidHost("anonymizer.client.ru")) + #expect(URLValidator.isValidHost("a.b.c.d.example.com")) + } + + @Test("Modern TLDs pass (no allow-list)") + func modernTLDs() { + #expect(URLValidator.isValidHost("example.app")) + #expect(URLValidator.isValidHost("example.dev")) + #expect(URLValidator.isValidHost("example.io")) + #expect(URLValidator.isValidHost("example.xyz")) + } + + @Test("Single-label host passes (localhost)") + func singleLabelHost() { + #expect(URLValidator.isValidHost("localhost")) + } + + @Test("IPv4 literal passes") + func ipv4Literal() { + #expect(URLValidator.isValidHost("192.168.1.1")) + #expect(URLValidator.isValidHost("10.0.0.1")) + } + + @Test("Hyphens inside labels pass") + func hyphensInside() { + #expect(URLValidator.isValidHost("host-with-dash.com")) + #expect(URLValidator.isValidHost("a-b-c.example.com")) + } + + @Test("Empty input fails") + func emptyFails() { + #expect(!URLValidator.isValidHost("")) + } + + @Test("Whitespace inside fails") + func whitespaceFails() { + #expect(!URLValidator.isValidHost("api mindbox ru")) + #expect(!URLValidator.isValidHost("\thost\t")) + } + + @Test("Underscore fails (RFC 1123)") + func underscoreFails() { + #expect(!URLValidator.isValidHost("host_name.com")) + } + + @Test("Leading/trailing hyphen fails") + func edgeHyphenFails() { + #expect(!URLValidator.isValidHost("-leading.com")) + #expect(!URLValidator.isValidHost("trailing-.com")) + } + + @Test("Empty labels fail") + func emptyLabelsFail() { + #expect(!URLValidator.isValidHost(".com")) + #expect(!URLValidator.isValidHost("api..mindbox.ru")) + #expect(!URLValidator.isValidHost("api.mindbox.ru.")) + } + + @Test("Embedded scheme fails (caller must strip first)") + func schemeFails() { + #expect(!URLValidator.isValidHost("https://api.mindbox.ru")) + } + + @Test("Path/query in host fails") + func pathFails() { + #expect(!URLValidator.isValidHost("api.mindbox.ru/path")) + #expect(!URLValidator.isValidHost("api.mindbox.ru?q=1")) + } + + @Test("Total length over 253 fails") + func tooLongFails() { + let label = String(repeating: "a", count: 60) // 60 chars per label, well-formed + let host = (1...5).map { _ in label }.joined(separator: ".") // 5*60 + 4 = 304 chars + #expect(!URLValidator.isValidHost(host)) + } + + @Test("63-char label is the max accepted") + func labelLengthBoundary() { + let valid = String(repeating: "a", count: 63) + ".com" + #expect(URLValidator.isValidHost(valid)) + let invalid = String(repeating: "a", count: 64) + ".com" + #expect(!URLValidator.isValidHost(invalid)) + } + + @Test("253-char total length is the max accepted") + func totalLengthBoundary() { + // 4 × 63-char labels + 3 dots = 255 → too long + let label63 = String(repeating: "a", count: 63) + let invalid = (1...4).map { _ in label63 }.joined(separator: ".") + #expect(invalid.count == 255) + #expect(!URLValidator.isValidHost(invalid)) + + // 3 × 63-char labels + 1 × 61-char label + 3 dots = 253 → valid + let label61 = String(repeating: "b", count: 61) + let valid = [label63, label63, label63, label61].joined(separator: ".") + #expect(valid.count == 253) + #expect(URLValidator.isValidHost(valid)) + } + + @Test("Single-character labels pass") + func singleCharLabels() { + #expect(URLValidator.isValidHost("a.b")) + #expect(URLValidator.isValidHost("x.y.z")) + } + + @Test("Mixed-case hosts pass") + func mixedCasePasses() { + #expect(URLValidator.isValidHost("API.Mindbox.RU")) + #expect(URLValidator.isValidHost("LocalHost")) + } + + @Test("Punycode IDN host passes (looks like alnum + hyphen)") + func punycodePasses() { + #expect(URLValidator.isValidHost("xn--80aswg.xn--p1ai")) + } + + @Test("Unicode literal IDN host fails (ASCII-only contract)") + func unicodeLiteralFails() { + #expect(!URLValidator.isValidHost("мойсайт.рф")) + } + + @Test("Special characters fail") + func specialCharsFail() { + #expect(!URLValidator.isValidHost("host!.com")) + #expect(!URLValidator.isValidHost("host*.com")) + #expect(!URLValidator.isValidHost("host%.com")) + #expect(!URLValidator.isValidHost("host:.com")) + #expect(!URLValidator.isValidHost("host@.com")) + } + + @Test("Single label with edge hyphen fails") + func singleLabelEdgeHyphen() { + #expect(!URLValidator.isValidHost("-host")) + #expect(!URLValidator.isValidHost("host-")) + } + + @Test("Single dot fails") + func singleDotFails() { + #expect(!URLValidator.isValidHost(".")) + } +} diff --git a/MindboxTests/Validators/ValidatorsTestCase.swift b/MindboxTests/Validators/ValidatorsTestCase.swift index 429b7306f..b6d2639a9 100644 --- a/MindboxTests/Validators/ValidatorsTestCase.swift +++ b/MindboxTests/Validators/ValidatorsTestCase.swift @@ -9,32 +9,8 @@ import XCTest @testable import Mindbox -// swiftlint:disable line_length - class ValidatorsTestCase: XCTestCase { - func testURLValidator() { - [ - "https://www.google.com/search?rlz=1C5CHFA_enRU848RU848&ei=GMYTYIKCK9SSwPAP8cWjiAM&q=umbrella+it&oq=umbrella+it&gs_lcp=CgZwc3ktYWIQAzIICAAQxwEQrwEyCAgAEMcBEK8BMgIIADICCAAyAggAMggIABDHARCvATICCAA6BQgAELEDOggIABCxAxCDAToICAAQxwEQowI6BggAEAoQAToOCAAQxwEQrwEQChABECo6BAgAEAo6BAgAEB46CgguELEDEEMQkwI6BAgAEEM6BwguELEDEEM6CggAEMcBEK8BEAo6BwgAELEDEAo6CwgAELEDEMcBEKMCOgUILhCxAzoOCAAQsQMQgwEQxwEQowI6CggAEAoQARBDECo6BwgAELEDEENQolhYx7IBYM61AWgJcAB4AIABcIgBqA2SAQQxNi40mAEAoAEBqgEHZ3dzLXdperABAMABAQ&sclient=psy-ab&ved=0ahUKEwiC7tnL28DuAhVUCRAIHfHiCDEQ4dUDCA0&uact=5", - - "http://www.google.com" - ] - .compactMap({ URL(string: $0) }) - .forEach { - XCTAssertTrue(URLValidator(url: $0).evaluate()) - } - - [ - "", - "https://www google com/", - "www.google.com" - ] - .compactMap { URL(string: $0) } - .forEach { - XCTAssertFalse(URLValidator(url: $0).evaluate()) - } - } - func testUDIDValidator() { XCTAssertFalse(UDIDValidator(udid: "00000000-0000-0000-0000-000000000000").evaluate()) XCTAssertFalse(UDIDValidator(udid: "00000000-0000-0000-0000").evaluate()) From 815c6834ce41cec848cf831ba9df62fb70024083 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:15:50 +0300 Subject: [PATCH 07/15] MOBILE-130: Tighten config-download fan-out and document softReset exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract `applyDownloadedConfig(_:rawData:)` and `sendMonitoringLogsIfNeeded(_:)` so the .data branch reads as decode → assign → apply. Group the three `SessionTemporaryStorage` writes inside `setupSettingsFromConfig` into `applySessionStorageSettings(_:)` so the dispatcher reads as four steps. Move the rationale for excluding `operationsDomainFromConfig` from `softReset()` to the property's docstring, where it belongs. --- .../InAppConfigurationManager.swift | 43 ++++++++++++------- .../PersistenceStorage.swift | 6 +-- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 9d3ad9e51..d069456f7 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -86,11 +86,7 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { do { let config = try jsonDecoder.decode(ConfigResponse.self, from: data) configResponse = config - saveConfigToCache(data) - setupSettingsFromConfig(config.settings) - if let monitoring = config.monitoring, let logsManager = DI.inject(SDKLogsManagerProtocol.self) { - logsManager.sendLogs(logs: monitoring.logs.elements) - } + applyDownloadedConfig(config, rawData: data) } catch { applyConfigFromCache() Logger.common(message: "Failed to parse downloaded config file. Error: \(error)", level: .error, category: .inAppMessages) @@ -104,11 +100,25 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { applyConfigFromCache() Logger.common(message: "Failed to download InApp configuration. Error: \(error.localizedDescription)", level: .error, category: .inAppMessages) } - + self.delegate?.didPreparedConfiguration() sendNotification(with: configResponse?.settings?.slidingExpiration?.pushTokenKeepalive) } + private func applyDownloadedConfig(_ config: ConfigResponse, rawData: Data) { + saveConfigToCache(rawData) + setupSettingsFromConfig(config.settings) + sendMonitoringLogsIfNeeded(config.monitoring) + } + + private func sendMonitoringLogsIfNeeded(_ monitoring: Monitoring?) { + guard let monitoring = monitoring, + let logsManager = DI.inject(SDKLogsManagerProtocol.self) else { + return + } + logsManager.sendLogs(logs: monitoring.logs.elements) + } + private func applyConfigFromCache() { guard var cachedConfig = self.fetchConfigFromCache() else { Logger.common(message: "Failed to apply configuration from cache: No cached configuration found.") @@ -149,23 +159,26 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { return } + applySessionStorageSettings(settings) + featureToggleManager.applyFeatureToggles(settings.featureToggles) + persistOperationsDomain(from: settings.baseAddresses) + saveConfigSessionToCache(settings.slidingExpiration?.config) + } + + private func applySessionStorageSettings(_ settings: Settings) { + let storage = SessionTemporaryStorage.shared + if let viewCategory = settings.operations?.viewCategory { - SessionTemporaryStorage.shared.viewCategoryOperation = viewCategory.systemName.lowercased() + storage.viewCategoryOperation = viewCategory.systemName.lowercased() } if let viewProduct = settings.operations?.viewProduct { - SessionTemporaryStorage.shared.viewProductOperation = viewProduct.systemName.lowercased() + storage.viewProductOperation = viewProduct.systemName.lowercased() } if let inappSettings = settings.inapp { - SessionTemporaryStorage.shared.inAppSettings = inappSettings + storage.inAppSettings = inappSettings } - - featureToggleManager.applyFeatureToggles(settings.featureToggles) - - persistOperationsDomain(from: settings.baseAddresses) - - saveConfigSessionToCache(settings.slidingExpiration?.config) } private func persistOperationsDomain(from baseAddresses: Settings.BaseAddresses?) { diff --git a/Mindbox/PersistenceStorage/PersistenceStorage.swift b/Mindbox/PersistenceStorage/PersistenceStorage.swift index 45e22e2bf..df7d067a8 100644 --- a/Mindbox/PersistenceStorage/PersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/PersistenceStorage.swift @@ -55,6 +55,9 @@ protocol PersistenceStorage: AnyObject { /// Operations host cached from `settings.baseAddresses.operations` in the mobile /// JSON config. Persisted across launches; takes precedence over the init-time /// `MBConfiguration.operationsDomain` at request time. + /// Excluded from `softReset()` on purpose: clearing it on a migration reset would + /// route operations to `domain` until the next config load, breaking the + /// PD-safety guarantee. var operationsDomainFromConfig: String? { get set } /// The version code used to track the current state of migrations. @@ -102,9 +105,6 @@ extension PersistenceStorage { func softReset() { configDownloadDate = nil - // `operationsDomainFromConfig` is intentionally preserved: clearing it on a - // migration reset would route operations to `domain` until config reloads, - // breaking the PD-safety guarantee. shownDatesByInApp = nil handledlogRequestIds = nil lastInappStateChangeDate = nil From 230ffcbaebd43a6c64d499b33869b9108e74f251 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:24:59 +0300 Subject: [PATCH 08/15] MOBILE-130: Clean up public docstring for MBConfiguration.operationsDomain --- Mindbox/MBConfiguration.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 6933238fe..b34e48ce8 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -32,9 +32,8 @@ public struct MBConfiguration: Codable { /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. /// - Parameter shouldCreateCustomer: Flag which determines create or not anonymous users. Usable only during first initialisation. Default value is `true`. /// - Parameter uuidDebugEnabled: Flag which determines if uuid debugging functionality is enabled. Default value is `true`. - /// - Parameter operationsDomain: Optional anonymizer host for `/v3/operations/*` and - /// `/v1.1/customer/mobile-track-visit`. Bare host without scheme. Overridden by the - /// value from the mobile JSON config when present. Default `nil` (use `domain`). + /// - Parameter operationsDomain: Optional host for sending operations. Overridden by + /// the value from the mobile JSON config when present. Default `nil` (use `domain`). /// /// - Throws:`MindboxError.internalError` for invalid initialization parameters public init( From 288d7912c3ab8715fa8ef9555163bf13b7c90647 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:29:18 +0300 Subject: [PATCH 09/15] MOBILE-130: Move operationsDomain next to domain in MBConfiguration init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new parameter now sits right after `domain` instead of at the end — they're conceptually paired (main host vs its anonymizer override). Source-compatible: callers that don't pass `operationsDomain` are unaffected; the few that do (tests, ios-app) already listed it adjacent to `domain` and only need a follow-up reorder of their own. --- Mindbox/MBConfiguration.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index b34e48ce8..47c75a430 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -27,25 +27,25 @@ public struct MBConfiguration: Codable { /// /// - Parameter endpoint: Used for app identification /// - Parameter domain: Used for generating baseurl for REST + /// - Parameter operationsDomain: Optional host for sending operations. Overridden by + /// the value from the mobile JSON config when present. Default `nil` (use `domain`). /// - Parameter previousInstallationId: Used to create tracking continuity by uuid /// - Parameter previousDeviceUUID: Used instead of the generated value /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. /// - Parameter shouldCreateCustomer: Flag which determines create or not anonymous users. Usable only during first initialisation. Default value is `true`. /// - Parameter uuidDebugEnabled: Flag which determines if uuid debugging functionality is enabled. Default value is `true`. - /// - Parameter operationsDomain: Optional host for sending operations. Overridden by - /// the value from the mobile JSON config when present. Default `nil` (use `domain`). /// /// - Throws:`MindboxError.internalError` for invalid initialization parameters public init( endpoint: String, domain: String, + operationsDomain: String? = nil, previousInstallationId: String? = nil, previousDeviceUUID: String? = nil, subscribeCustomerIfCreated: Bool = false, shouldCreateCustomer: Bool = true, imageLoadingMaxTimeInSeconds: Double? = nil, - uuidDebugEnabled: Bool = true, - operationsDomain: String? = nil + uuidDebugEnabled: Bool = true ) throws { self.endpoint = endpoint self.domain = domain @@ -183,12 +183,12 @@ public struct MBConfiguration: Codable { try self.init( endpoint: endpoint, domain: domain, + operationsDomain: operationsDomain, previousInstallationId: previousInstallationId, previousDeviceUUID: previousDeviceUUID, subscribeCustomerIfCreated: subscribeCustomerIfCreated, shouldCreateCustomer: shouldCreateCustomer, - uuidDebugEnabled: uuidDebugEnabled, - operationsDomain: operationsDomain + uuidDebugEnabled: uuidDebugEnabled ) } } From 7c83fd3d6848c18d67ae7e7edbe6101352bb9c37 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:47:15 +0300 Subject: [PATCH 10/15] MOBILE-130: Store operationsDomain from JSON config in canonical scheme://host form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend may send `http://` for some anonymizer setups; previous logic stored the raw string verbatim, so trailing slashes / scheme-vs-host mismatches caused spurious re-saves. Normalize via HostNormalizer.toBaseURLString on save: scheme preserved (http/https), trailing slash stripped, missing scheme defaults to https. Idempotent — first config fetch after upgrade rewrites legacy values once, then `.keep` stably. Adapts existing Policy tests to the canonical form and adds coverage for the trailing-slash case from JSON config, http preservation, canonical-equality keep, and the legacy-value upgrade path. --- .../OperationsDomainConfigPolicy.swift | 5 +- .../Network/OperationsURLRoutingTests.swift | 48 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index c9fbbcc60..eae9a598c 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -30,6 +30,9 @@ enum OperationsDomainConfigPolicy { return .keep } - return value == currentlyStored ? .keep : .save(value) + // Store canonical `scheme://host` so backend's choice of `http`/`https` + // is preserved across restarts and trailing slashes don't cause re-saves. + let normalized = HostNormalizer.toBaseURLString(value) + return normalized == currentlyStored ? .keep : .save(normalized) } } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 3ce1b119c..2e0f7be4c 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -198,27 +198,27 @@ struct OperationsURLRoutingTests { @Test("Policy — saves a new valid value when storage is empty") func policySavesNewValueFromEmpty() { - #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("x.ru")) + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("https://x.ru")) } @Test("Policy — saves when value changes") func policySavesOnChange() { - #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "old.ru") == .save("new.ru")) + #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "https://old.ru") == .save("https://new.ru")) } @Test("Policy — keeps when incoming value equals stored") func policyKeepsOnIdenticalValue() { - #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "https://x.ru", currentlyStored: "https://x.ru") == .keep) } @Test("Policy — clears on null/missing config when something is stored") func policyClearsOnNullWhenStored() { - #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "old.ru") == .clear) + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "https://old.ru") == .clear) } @Test("Policy — clears on empty string when something is stored") func policyClearsOnEmptyWhenStored() { - #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "old.ru") == .clear) + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "https://old.ru") == .clear) } @Test("Policy — no-ops when nothing stored and nothing came") @@ -229,7 +229,43 @@ struct OperationsURLRoutingTests { @Test("Policy — preserves previous value when incoming host is format-broken") func policyKeepsOnInvalidFormat() { - #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "good.ru") == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") == .keep) + } + + @Test("Policy — normalizes scheme + trailing slash to canonical form") + func policyNormalizesSchemeAndTrailingSlash() { + #expect( + OperationsDomainConfigPolicy.action( + for: "https://anonymizer-api-regular.client.ru/", + currentlyStored: nil + ) == .save("https://anonymizer-api-regular.client.ru") + ) + } + + @Test("Policy — preserves http scheme from config (does not force https)") + func policyPreservesHttpScheme() { + #expect( + OperationsDomainConfigPolicy.action(for: "http://x.ru/", currentlyStored: nil) + == .save("http://x.ru") + ) + } + + @Test("Policy — keeps when canonical form equals stored despite raw differences") + func policyKeepsWhenCanonicalFormMatches() { + #expect( + OperationsDomainConfigPolicy.action( + for: "https://x.ru/", + currentlyStored: "https://x.ru" + ) == .keep + ) + } + + @Test("Policy — upgrade path: legacy bare-host stored value re-saves once as canonical") + func policyUpgradesLegacyStoredValue() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") + == .save("https://x.ru") + ) } // MARK: - Persistence lifecycle From 030ed8fc408c67209f24eb99beb4b2e902e5117e Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:27:08 +0300 Subject: [PATCH 11/15] MOBILE-130: Cover operationsDomain http/trailing-slash parity in init and routing tests Routing tests had three scheme/slash cases for `domain` (bare host, https preserved, http preserved, trailing-slash stripped) but only `https://` for `operationsDomain`. Same asymmetry in MBConfiguration init tests. Add the missing http/trailing-slash cases for `operationsDomain` in both, plus an end-to-end check that the canonical `scheme://host` form written by OperationsDomainConfigPolicy routes correctly through URLRequestBuilder. --- .../Configuration/MBConfigurationTests.swift | 20 ++++++++ .../Network/OperationsURLRoutingTests.swift | 49 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index 76320010e..dc3425ccf 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -67,6 +67,26 @@ struct MBConfigurationTests { #expect(config.operationsDomain == "https://anonymizer.client.ru") } + @Test("operationsDomain accepts http:// prefix") + func operationsDomainAcceptsHttpPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "http://anonymizer-staging.client.ru" + ) + #expect(config.operationsDomain == "http://anonymizer-staging.client.ru") + } + + @Test("operationsDomain accepts trailing slash") + func operationsDomainAcceptsTrailingSlash() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "anonymizer.client.ru/" + ) + #expect(config.operationsDomain == "anonymizer.client.ru/") + } + // MARK: - Init: endpoint validation @Test("Empty endpoint throws") diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 2e0f7be4c..32d0fcf3d 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -99,6 +99,16 @@ struct OperationsURLRoutingTests { #expect(url?.host == "proxy.example.com") } + @Test("Bare operationsDomain gets default https:// scheme") + func bareOperationsDomainUsesHttps() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "anonymizer.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + } + @Test("Explicit https:// in operationsDomain is preserved") func explicitHttpsInOperationsDomainPreserved() throws { let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru") @@ -109,8 +119,18 @@ struct OperationsURLRoutingTests { #expect(url?.host == "anonymizer.client.ru") } - @Test("Trailing slash in host input is stripped before path append") - func trailingSlashStripped() throws { + @Test("Explicit http:// in operationsDomain is preserved (proxy/staging case)") + func explicitHttpInOperationsDomainPreserved() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "http://anonymizer-staging.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "http") + #expect(url?.host == "anonymizer-staging.client.ru") + } + + @Test("Trailing slash in domain is stripped before path append") + func trailingSlashInDomainStripped() throws { let builder = URLRequestBuilder(domain: "api.mindbox.ru/") let wrapper = Self.makeEventWrapper(.installed) @@ -119,6 +139,31 @@ struct OperationsURLRoutingTests { #expect(url?.host == "api.mindbox.ru") } + @Test("Trailing slash in operationsDomain is stripped before path append") + func trailingSlashInOperationsDomainStripped() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + #expect(url?.path == "/v3/operations/async") + } + + @Test("Canonical form stored by policy routes correctly end-to-end") + func canonicalStoredFormRoutesCorrectly() throws { + // Mirrors what `OperationsDomainConfigPolicy` writes to PersistenceStorage: + // canonical `scheme://host` form. URLRequestBuilder must accept it as-is. + let canonical = "https://anonymizer-api-regular.client.ru" + let builder = URLRequestBuilder(domain: domain, operationsDomain: canonical) + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer-api-regular.client.ru") + #expect(url?.path == "/v3/operations/async") + } + // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` From 4abbd76f5fce8dc4e7a0649b05c1e272e6f5b976 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:56:30 +0300 Subject: [PATCH 12/15] MOBILE-130: Address review feedback on operationsDomain decode and reject path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MBConfiguration decoder: drop the `try?` on `decodeIfPresent` so the result is `String?` not `String??`. Type errors propagate, matching how `endpoint`, `domain`, and the bool fields are decoded. - OperationsDomainConfigPolicy: split `.keep` into `.keep` and `.rejected(String)`. Previously `.keep` collapsed both "already in canonical form" and "invalid format — fall back". After the canonicalization fix this caused spurious "Invalid domain" error logs whenever a legacy raw value (e.g. `x.ru`) matched a stored canonical form (`https://x.ru`). - InAppConfigurationManager: handle the new `.rejected` case explicitly, drop the brittle `current != raw` condition that motivated the bug. - Regression test covers the legacy-vs-canonical match no longer being mis-logged as rejected. --- .../InAppConfigurationManager.swift | 7 +++---- .../OperationsDomainConfigPolicy.swift | 10 +++++++--- Mindbox/MBConfiguration.swift | 2 +- .../Network/OperationsURLRoutingTests.swift | 19 ++++++++++++++++--- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index d069456f7..09f17ebe6 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -187,16 +187,15 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { switch OperationsDomainConfigPolicy.action(for: raw, currentlyStored: current) { case .keep: - if let raw = raw, !raw.isEmpty, current != raw { - // `.keep` on a non-empty value means it was rejected by URLValidator. - Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(raw)", level: .error, category: .inAppMessages) - } + break case .clear: persistenceStorage.operationsDomainFromConfig = nil Logger.common(message: "[OperationsDomain] Cleared — config has no value.", level: .info, category: .inAppMessages) case .save(let value): persistenceStorage.operationsDomainFromConfig = value Logger.common(message: "[OperationsDomain] Updated from config. [Value]: \(value)", level: .info, category: .inAppMessages) + case .rejected(let value): + Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(value)", level: .error, category: .inAppMessages) } } diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index eae9a598c..60cf0cd53 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -16,9 +16,13 @@ enum OperationsDomainConfigPolicy { case save(String) /// Config explicitly cleared the value (null / missing / empty). case clear - /// No-op: equal to stored, both empty, or incoming value is format-broken - /// (one bad push must not destroy a working config). + /// No-op: nothing stored and nothing came, or canonicalized incoming + /// value already equals the stored one. case keep + /// Incoming value is format-broken — previous value kept intact + /// (one bad push must not destroy a working config). Carries the raw + /// input so the caller can log it. + case rejected(String) } static func action(for raw: String?, currentlyStored: String?) -> Action { @@ -27,7 +31,7 @@ enum OperationsDomainConfigPolicy { } guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { - return .keep + return .rejected(value) } // Store canonical `scheme://host` so backend's choice of `http`/`https` diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 47c75a430..ed73d63dd 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -164,7 +164,7 @@ public struct MBConfiguration: Codable { let values = try decoder.container(keyedBy: CodingKeys.self) let endpoint = try values.decode(String.self, forKey: .endpoint) let domain = try values.decode(String.self, forKey: .domain) - let operationsDomain = try? values.decodeIfPresent(String.self, forKey: .operationsDomain) + let operationsDomain = try values.decodeIfPresent(String.self, forKey: .operationsDomain) var previousInstallationId: String? if let value = try? values.decode(String.self, forKey: .previousInstallationId) { if !value.isEmpty { diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 32d0fcf3d..07c5726a3 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -272,9 +272,22 @@ struct OperationsURLRoutingTests { #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: nil) == .keep) } - @Test("Policy — preserves previous value when incoming host is format-broken") - func policyKeepsOnInvalidFormat() { - #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") == .keep) + @Test("Policy — rejects format-broken incoming value (previous kept intact)") + func policyRejectsInvalidFormat() { + #expect( + OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") + == .rejected("host with spaces") + ) + } + + @Test("Policy — does NOT spuriously reject when canonical form matches stored (legacy raw)") + func policyDoesNotRejectOnLegacyRawMatch() { + // Pre-fix bug: `.keep` was logged as "rejected" whenever raw != stored, + // even when raw was valid and just normalized to the stored form. + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "https://x.ru") + == .keep + ) } @Test("Policy — normalizes scheme + trailing slash to canonical form") From ee494c10f7c349f5bff2c81a6e977e4413df8177 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:05:54 +0300 Subject: [PATCH 13/15] MOBILE-130: Address review feedback on validator strictness and URL build safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - URLValidator: enforce IPv4 octet ranges (0..255) for four-pure-digit-label inputs, matching Android's PatternsCompat.DOMAIN_NAME. `999.999.999.999` and `256.0.0.0` are now rejected; structural hostname rules unchanged. - URLRequestBuilder: stop silently falling back to an empty URLComponents when `URLComponents(string:)` fails — that produced relative URLs that passed the existing `components.url != nil` guard and hit the network with a bogus target. Throw `URLError.badURL` at the source. - Tests cover IPv4 overflow rejection, hostname pass-through for non-IPv4 numeric counts (3 / 5 labels), and the new fail-fast path. --- .../OperationsDomainConfigPolicy.swift | 1 + .../Network/Helpers/URLRequestBuilder.swift | 14 ++++++++--- Mindbox/Validators/URLValidator.swift | 21 +++++++++++++---- .../Network/OperationsURLRoutingTests.swift | 18 ++++++++++++--- .../Validators/URLValidatorTests.swift | 23 ++++++++++++++++++- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 60cf0cd53..f1b456c5f 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -15,6 +15,7 @@ enum OperationsDomainConfigPolicy { enum Action: Equatable { case save(String) /// Config explicitly cleared the value (null / missing / empty). + /// Caller falls back through the priority chain (init → domain). case clear /// No-op: nothing stored and nothing came, or canonicalized incoming /// value already equals the stored one. diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index a73aac867..60ba12625 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -20,7 +20,7 @@ struct URLRequestBuilder { } func asURLRequest(route: Route) throws -> URLRequest { - let components = makeURLComponents(for: route) + let components = try makeURLComponents(for: route) guard let url = components.url else { Logger.common(message: "Bad url. [URL]: \(String(describing: components.url))", level: .error, category: .network) @@ -37,9 +37,17 @@ struct URLRequestBuilder { return urlRequest } - private func makeURLComponents(for route: Route) -> URLComponents { + private func makeURLComponents(for route: Route) throws -> URLComponents { let baseURL = HostNormalizer.toBaseURLString(resolvedHost(for: route)) - var components = URLComponents(string: baseURL) ?? URLComponents() + + // Fail fast: if the base URL is unparseable, we used to fall back to an + // empty `URLComponents()` — `components.url` then returned a relative URL + // (just the path), which silently sent the request to a bogus target. + guard var components = URLComponents(string: baseURL) else { + Logger.common(message: "Failed to build base URL components. [Base]: \(baseURL)", level: .error, category: .network) + throw URLError(.badURL) + } + components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index 8f2e3f9c9..eb973f5c1 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -10,7 +10,8 @@ import Foundation /// Validates a bare hostname (e.g. `api.mindbox.ru`, `localhost`, `192.168.1.1`) /// using RFC 1123 label structure. No TLD allow-list — new TLDs (`.app`, `.dev`, …) -/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`. +/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`, +/// including its IPv4 octet-range enforcement. enum URLValidator { /// RFC 1035: full hostname max 253 chars. @@ -21,9 +22,16 @@ enum URLValidator { static func isValidHost(_ host: String) -> Bool { guard !host.isEmpty, host.count <= maxHostLength else { return false } - return host - .split(separator: ".", omittingEmptySubsequences: false) - .allSatisfy(isValidLabel) + + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + + // Four pure-digit labels = IPv4 literal — enforce octet ranges so + // `999.999.999.999` is rejected (matches Android's PatternsCompat). + if labels.count == 4, labels.allSatisfy({ $0.allSatisfy(\.isASCII) && $0.allSatisfy(\.isNumber) }) { + return labels.allSatisfy(isValidIPv4Octet) + } + + return labels.allSatisfy(isValidLabel) } private static func isValidLabel(_ label: Substring) -> Bool { @@ -34,6 +42,11 @@ enum URLValidator { return label.unicodeScalars.allSatisfy(isAlnumOrHyphen) } + private static func isValidIPv4Octet(_ label: Substring) -> Bool { + guard (1...3).contains(label.count), let value = Int(label) else { return false } + return (0...255).contains(value) + } + private static func isAlnumOrHyphen(_ scalar: Unicode.Scalar) -> Bool { ("a"..."z").contains(scalar) || ("A"..."Z").contains(scalar) diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 07c5726a3..2407c5428 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -164,6 +164,18 @@ struct OperationsURLRoutingTests { #expect(url?.path == "/v3/operations/async") } + @Test("Fails fast when base URL is unparseable (no silent relative-URL request)") + func failsFastOnUnparseableBaseURL() { + // Embedded space defeats both `URLComponents(string:)` parsing and + // makes the prior fallback build a bogus relative URL silently. + let builder = URLRequestBuilder(domain: "bad host with spaces") + let wrapper = Self.makeEventWrapper(.installed) + + #expect(throws: URLError.self) { + _ = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)) + } + } + // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` @@ -256,12 +268,12 @@ struct OperationsURLRoutingTests { #expect(OperationsDomainConfigPolicy.action(for: "https://x.ru", currentlyStored: "https://x.ru") == .keep) } - @Test("Policy — clears on null/missing config when something is stored") + @Test("Policy — clears on null/missing config when something is stored (rollback)") func policyClearsOnNullWhenStored() { #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "https://old.ru") == .clear) } - @Test("Policy — clears on empty string when something is stored") + @Test("Policy — clears on empty string when something is stored (rollback)") func policyClearsOnEmptyWhenStored() { #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "https://old.ru") == .clear) } @@ -282,7 +294,7 @@ struct OperationsURLRoutingTests { @Test("Policy — does NOT spuriously reject when canonical form matches stored (legacy raw)") func policyDoesNotRejectOnLegacyRawMatch() { - // Pre-fix bug: `.keep` was logged as "rejected" whenever raw != stored, + // Regression: `.keep` was previously logged as "rejected" whenever raw != stored, // even when raw was valid and just normalized to the stored form. #expect( OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "https://x.ru") diff --git a/MindboxTests/Validators/URLValidatorTests.swift b/MindboxTests/Validators/URLValidatorTests.swift index e5d294d60..3c6eca02f 100644 --- a/MindboxTests/Validators/URLValidatorTests.swift +++ b/MindboxTests/Validators/URLValidatorTests.swift @@ -33,10 +33,31 @@ struct URLValidatorTests { #expect(URLValidator.isValidHost("localhost")) } - @Test("IPv4 literal passes") + @Test("Valid IPv4 literals pass") func ipv4Literal() { #expect(URLValidator.isValidHost("192.168.1.1")) #expect(URLValidator.isValidHost("10.0.0.1")) + #expect(URLValidator.isValidHost("0.0.0.0")) + #expect(URLValidator.isValidHost("255.255.255.255")) + } + + @Test("IPv4 octet > 255 fails (parity with Android PatternsCompat)") + func ipv4OctetOverflowFails() { + #expect(!URLValidator.isValidHost("999.999.999.999")) + #expect(!URLValidator.isValidHost("256.0.0.0")) + #expect(!URLValidator.isValidHost("192.168.1.256")) + } + + @Test("Three numeric labels are NOT treated as IPv4 — fall through to hostname rules") + func threeNumericLabelsAreHostname() { + // 1.2.3 is not IPv4 (3 labels, not 4) and remains structurally a valid hostname. + #expect(URLValidator.isValidHost("1.2.3")) + } + + @Test("Five numeric labels are NOT treated as IPv4 — fall through to hostname rules") + func fiveNumericLabelsAreHostname() { + // 5 labels of digits are structurally a valid hostname even though not IPv4. + #expect(URLValidator.isValidHost("1.2.3.4.5")) } @Test("Hyphens inside labels pass") From 573d0cfb988c86f2a8b0900e0320997d52559dbe Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:20:33 +0300 Subject: [PATCH 14/15] MOBILE-130: Drop .prettyPrinted from trackVisit body serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `JSONSerialization.data(withJSONObject:options:)` was using `.prettyPrinted` for the trackVisit request body — wasted whitespace and indentation on every mobile-track-visit call. Server parses both forms identically; the change just trims a few dozen bytes per request and matches the codebase convention for network-path serialization. Default `options` value (`[]`) is fine, so the parameter is dropped entirely. --- Mindbox/NetworkRepository/Event/EventRoute.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mindbox/NetworkRepository/Event/EventRoute.swift b/Mindbox/NetworkRepository/Event/EventRoute.swift index 9ff8d4c90..17e6c0995 100644 --- a/Mindbox/NetworkRepository/Event/EventRoute.swift +++ b/Mindbox/NetworkRepository/Event/EventRoute.swift @@ -89,7 +89,7 @@ enum EventRoute: Route { json["endpointId"] = wrapper.endpoint - return try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + return try? JSONSerialization.data(withJSONObject: json) } } From bafc9f4527b6bafee21fab554103a15123a6dead Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:23:39 +0300 Subject: [PATCH 15/15] MOBILE-130: Use case-insensitive anchored range matching in HostNormalizer Replace `value.lowercased().hasPrefix(...)` with `value.range(of:options:[.caseInsensitive, .anchored])` so the scheme check no longer allocates a lowercased copy of the input on every call. Identical behaviour, cleaner intent (prefix-with-options vs. transform-then-check), and the prefix literals are pulled out as `static let` so `dropFirst(...)` no longer carries hard-coded length math. Adds a tiny `hasSchemePrefix` helper for `toBaseURLString` to avoid duplicating both range checks inline. --- Mindbox/Network/Helpers/HostNormalizer.swift | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index a047cbfe1..1dc2320b7 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -12,13 +12,16 @@ import Foundation /// Accepts `host`, `https://host`, `http://host`, with or without trailing slash. enum HostNormalizer { + private static let httpsPrefix = "https://" + private static let httpPrefix = "http://" + /// Strips scheme (case-insensitive), whitespace, and trailing slashes. static func extractHost(_ raw: String) -> String { var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if value.lowercased().hasPrefix("https://") { - value = String(value.dropFirst("https://".count)) - } else if value.lowercased().hasPrefix("http://") { - value = String(value.dropFirst("http://".count)) + if value.range(of: httpsPrefix, options: [.caseInsensitive, .anchored]) != nil { + value = String(value.dropFirst(httpsPrefix.count)) + } else if value.range(of: httpPrefix, options: [.caseInsensitive, .anchored]) != nil { + value = String(value.dropFirst(httpPrefix.count)) } while value.hasSuffix("/") { value.removeLast() @@ -30,10 +33,15 @@ enum HostNormalizer { static func toBaseURLString(_ raw: String) -> String { var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) while value.hasSuffix("/") { value.removeLast() } - let lower = value.lowercased() - if lower.hasPrefix("http://") || lower.hasPrefix("https://") { + if hasSchemePrefix(value) { return value } - return "https://" + value + return httpsPrefix + value + } + + private static func hasSchemePrefix(_ value: String) -> Bool { + let options: String.CompareOptions = [.caseInsensitive, .anchored] + return value.range(of: httpsPrefix, options: options) != nil + || value.range(of: httpPrefix, options: options) != nil } }