diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index 300c689bf..fa249b703 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} fetch-depth: 3 diff --git a/.github/workflows/gitleaks-secrets-validate.yml b/.github/workflows/gitleaks-secrets-validate.yml index 31312bc20..8f412cc48 100644 --- a/.github/workflows/gitleaks-secrets-validate.yml +++ b/.github/workflows/gitleaks-secrets-validate.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/linter_and_unit_tests.yml b/.github/workflows/linter_and_unit_tests.yml index 35404015c..fd1a481bc 100644 --- a/.github/workflows/linter_and_unit_tests.yml +++ b/.github/workflows/linter_and_unit_tests.yml @@ -18,9 +18,9 @@ jobs: SwiftLint: runs-on: ubuntu-latest container: - image: ghcr.io/realm/swiftlint:0.63.3@sha256:b70b763c949399cc3e4fdcfbc7588944d9503d498b6d96e853dfa07a944256e6 + image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: swiftlint --config .swiftlint.yml --reporter github-actions-logging --strict build: @@ -31,7 +31,7 @@ jobs: checks: write pull-requests: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: latest-stable @@ -54,7 +54,7 @@ jobs: # survive regardless of the console formatter. Only failures are listed # (include_passed: false), so the PR comment stays compact even on green runs. - name: Publish unit-test summary - uses: mikepenz/action-junit-report@3a81627bfac62268172037048872e8ebd4207e6d # v6.4.1 + uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2 if: ${{ !cancelled() }} with: report_paths: test_output/report.junit diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index bb3f2a581..9c83cdddc 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -40,7 +40,7 @@ jobs: needs: validate-input steps: - name: Checkout minimal repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -74,7 +74,7 @@ jobs: release_branch: ${{ steps.bump.outputs.release_branch }} steps: - name: Checkout source branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 @@ -108,7 +108,7 @@ jobs: needs: bump_and_branch steps: - name: Checkout the release branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.bump_and_branch.outputs.release_branch }} fetch-depth: 0 diff --git a/.github/workflows/prepare_release_branch.yml b/.github/workflows/prepare_release_branch.yml index ebb90db96..8e10be19c 100644 --- a/.github/workflows/prepare_release_branch.yml +++ b/.github/workflows/prepare_release_branch.yml @@ -39,7 +39,7 @@ jobs: version2: ${{ steps.bump.outputs.version2 }} steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Bump version env: @@ -59,7 +59,7 @@ jobs: needs: bump_version steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -83,7 +83,7 @@ jobs: needs: check_sdk_version steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Create Pull Request env: diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index 02d4ce435..ea12731a4 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -19,7 +19,7 @@ jobs: unit-tests: runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 @@ -44,7 +44,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Extract SDK version @@ -69,7 +69,7 @@ jobs: needs: [set-tag] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -94,7 +94,7 @@ jobs: needs: [delay] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -115,7 +115,7 @@ jobs: needs: [check-podspecs-with-retry] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -142,7 +142,7 @@ jobs: needs: [second-delay] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -164,7 +164,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Release generation @@ -181,7 +181,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} steps: - name: Checkout develop branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: develop - name: Create Pull Request diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index 22f51b42c..333ebf538 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -32,13 +32,13 @@ jobs: if: ${{ github.base_ref == 'master' && startsWith(github.head_ref, 'release/') }} steps: - name: Checkout master branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: master path: master - name: Checkout release branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.head_ref }} path: release diff --git a/Gemfile.lock b/Gemfile.lock index 3f1deda51..e720eb256 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -245,7 +245,7 @@ GEM i18n (1.15.2) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.20.0) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) @@ -421,7 +421,7 @@ CHECKSUMS httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 diff --git a/Mindbox.podspec b/Mindbox.podspec index dead59569..bb46631d6 100644 --- a/Mindbox.podspec +++ b/Mindbox.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "Mindbox" - spec.version = "2.15.2" + spec.version = "2.15.3" spec.summary = "SDK for integration with Mindbox" spec.description = "This library allows you to integrate data transfer to Mindbox Marketing Cloud" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" @@ -14,6 +14,6 @@ Pod::Spec.new do |spec| 'Mindbox' => ['Mindbox/**/*.xcassets', 'Mindbox/**/*.xcdatamodeld', 'Mindbox/**/*.xcprivacy'] } spec.swift_version = "5" - spec.dependency 'MindboxLogger', '2.15.2' + spec.dependency 'MindboxLogger', '2.15.3' end diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 0b8ca3567..369cf72c4 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -179,6 +179,8 @@ 4731A81A2F447C3100CBE1E5 /* ConfigMonitoringError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A79D2F447C3100CBE1E5 /* ConfigMonitoringError.json */; }; 4731A81B2F447C3100CBE1E5 /* ConfigSettingsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7A02F447C3100CBE1E5 /* ConfigSettingsTypeError.json */; }; 4731A81C2F447C3100CBE1E5 /* MonitoringConfig.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */; }; + AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; + 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 4731A81D2F447C3100CBE1E5 /* InAppFrequencyError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7AA2F447C3100CBE1E5 /* InAppFrequencyError.json */; }; 4731A81E2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */; }; 4731A81F2F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7E82F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json */; }; @@ -383,10 +385,12 @@ A153E03F29BB002A003C34D4 /* SessionTemporaryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */; }; A153E04129BB0A8B003C34D4 /* InAppConfigurationWithOperations.json in Resources */ = {isa = PBXBuildFile; fileRef = A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */; }; A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */; }; + 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; A154E330299E0F1600F8F074 /* InAppGeoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */; }; A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E333299E110E00F8F074 /* EventRepositoryMock.swift */; }; A15D701629AF810E007131E7 /* SDKLogsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */; }; A15D701A29AF8142007131E7 /* SDKLogsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E382299E5B7500F8F074 /* SDKLogsManager.swift */; }; + 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; A15D704729AF81DC007131E7 /* MBLoggerCoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E34F299E5B6D00F8F074 /* MBLoggerCoreDataManager.swift */; }; A170EDDC29B0883800CE547F /* MindboxLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17853BE29AF7E940072578F /* MindboxLogger.framework */; }; A170EDE229B08A2700CE547F /* MindboxLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = A170EDE129B08A2700CE547F /* MindboxLogger.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -454,6 +458,7 @@ A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */; }; + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; B36D57852696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36D57842696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift */; }; B3A6254C2689F83100B6A3B7 /* PersonalOffersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254B2689F83100B6A3B7 /* PersonalOffersResponse.swift */; }; B3A625502689F8B600B6A3B7 /* BenefitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254F2689F8B600B6A3B7 /* BenefitResponse.swift */; }; @@ -926,6 +931,8 @@ 4731A7B62F447C3100CBE1E5 /* InAppTargetingError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingError.json; sourceTree = ""; }; 4731A7B72F447C3100CBE1E5 /* InAppTargetingTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingTypeError.json; sourceTree = ""; }; 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringConfig.json; sourceTree = ""; }; + EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; + 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsElementsMixedError.json; sourceTree = ""; }; 4731A7BC2F447C3100CBE1E5 /* MonitoringLogsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsError.json; sourceTree = ""; }; 4731A7BD2F447C3100CBE1E5 /* MonitoringLogsOneElementError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOneElementError.json; sourceTree = ""; }; @@ -1130,6 +1137,7 @@ A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTemporaryStorage.swift; sourceTree = ""; }; A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = InAppConfigurationWithOperations.json; sourceTree = ""; }; A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKLogManagerTests.swift; sourceTree = ""; }; + 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppGeoResponse.swift; sourceTree = ""; }; A154E333299E110E00F8F074 /* EventRepositoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRepositoryMock.swift; sourceTree = ""; }; A154E33A299E5B6D00F8F074 /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; @@ -1150,6 +1158,7 @@ A154E37F299E5B7500F8F074 /* SDKLogsStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsStatus.swift; sourceTree = ""; }; A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsRequest.swift; sourceTree = ""; }; A154E382299E5B7500F8F074 /* SDKLogsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsManager.swift; sourceTree = ""; }; + 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; A170EDE129B08A2700CE547F /* MindboxLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MindboxLogger.h; sourceTree = ""; }; A17853BE29AF7E940072578F /* MindboxLogger.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MindboxLogger.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A17853C529AF7E950072578F /* MindboxLoggerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MindboxLoggerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1233,6 +1242,7 @@ BD1BE43AA9EAEA03F8ED400C /* HapticRequestParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestParserTests.swift; sourceTree = ""; }; BD1BE43AA9EAEA03F8ED400D /* HapticRequestValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidatorTests.swift; sourceTree = ""; }; BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBEventRepositorySendRawTests.swift; sourceTree = ""; }; + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigrationTests.swift; sourceTree = ""; }; D216DE502C0716B70020F58A /* StringExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeIntervalTimeSpanTests.swift; sourceTree = ""; }; @@ -2019,6 +2029,8 @@ isa = PBXGroup; children = ( 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */, + EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */, + 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */, 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */, 4731A7BC2F447C3100CBE1E5 /* MonitoringLogsError.json */, 4731A7BD2F447C3100CBE1E5 /* MonitoringLogsOneElementError.json */, @@ -2309,6 +2321,7 @@ F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */, + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */, ); path = Network; sourceTree = ""; @@ -2844,6 +2857,7 @@ children = ( A154E332299E10F400F8F074 /* Mocks */, A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */, + 2A183433F63D47F684846B0F /* MD5HashTests.swift */, ); path = MindboxLogger; sourceTree = ""; @@ -3022,6 +3036,7 @@ children = ( A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */, A154E382299E5B7500F8F074 /* SDKLogsManager.swift */, + 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */, ); path = MindboxLogger; sourceTree = ""; @@ -4220,6 +4235,8 @@ 4731A81A2F447C3100CBE1E5 /* ConfigMonitoringError.json in Resources */, 4731A81B2F447C3100CBE1E5 /* ConfigSettingsTypeError.json in Resources */, 4731A81C2F447C3100CBE1E5 /* MonitoringConfig.json in Resources */, + AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */, + 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */, 4731A81D2F447C3100CBE1E5 /* InAppFrequencyError.json in Resources */, 4731A81E2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json in Resources */, 4731A81F2F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json in Resources */, @@ -4591,6 +4608,7 @@ B3F4F97C268EEA950092EC3C /* StatusResponse.swift in Sources */, 9B24FAAE28C74BA500F10B5D /* InAppCoreManager.swift in Sources */, A15D701A29AF8142007131E7 /* SDKLogsManager.swift in Sources */, + 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */, 334F3AF6264C199900A6AC00 /* ProductListRequest.swift in Sources */, F36128492BA3193E000382D9 /* PresentationClickTracker.swift in Sources */, 47BD5BFE2C578FB400F965C0 /* BaseMigration.swift in Sources */, @@ -4778,6 +4796,7 @@ F367301D2B7B8B6A00DD0039 /* NotificationFormatTests.swift in Sources */, F35E0C4F2DF0535E00E8A768 /* InAppTrackingServiceTests.swift in Sources */, A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */, + 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */, 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */, D2F7E2482BADB9EF00B24BB8 /* UserVisitManagerTests.swift in Sources */, BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */, @@ -4870,6 +4889,7 @@ F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */, + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */, EA395B77BB16CEFE6DC91D1D /* TransparentViewSyncOperationResponseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mindbox/CoreController/CoreController.swift b/Mindbox/CoreController/CoreController.swift index 668a05583..4428014e5 100644 --- a/Mindbox/CoreController/CoreController.swift +++ b/Mindbox/CoreController/CoreController.swift @@ -259,6 +259,7 @@ final class CoreController { controllerQueue: DispatchQueue = DispatchQueue(label: "com.Mindbox.controllerQueue"), userVisitManager: UserVisitManagerProtocol ) { + Logger.common(message: "════════════════ [Cold start] SDK initialization ════════════════", level: .info, category: .general) self.persistenceStorage = persistenceStorage self.utilitiesFetcher = utilitiesFetcher self.databaseRepository = databaseRepository diff --git a/Mindbox/DI/Injections/InjectReplaceable.swift b/Mindbox/DI/Injections/InjectReplaceable.swift index fa41f206d..827523c67 100644 --- a/Mindbox/DI/Injections/InjectReplaceable.swift +++ b/Mindbox/DI/Injections/InjectReplaceable.swift @@ -34,13 +34,13 @@ extension MBContainer { guard !appGroup.isEmpty else { // App Group unavailable (already reported by the fetcher) — fall back to - // `.standard` so the SDK keeps working instead of crashing the host (issue #705). + // `.standard` so the SDK keeps working instead of crashing the host. // A later App Group fix re-registers the install; see `AppGroupStorageTransitionReporter`. return MBPersistenceStorage(defaults: .standard) } guard let defaults = UserDefaults(suiteName: appGroup) else { - // Unreachable for a resolved App Group id; degrade without trapping (issue #705). + // Unreachable for a resolved App Group id; degrade without trapping. Logger.common(message: "[PersistenceStorage] UserDefaults(suiteName: \(appGroup)) failed; using .standard.", level: .fault, category: .general) return MBPersistenceStorage(defaults: .standard) } diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 5cd4adf39..9f9585553 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -120,10 +120,12 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { } private func sendMonitoringLogsIfNeeded(_ monitoring: Monitoring?) { - guard let monitoring = monitoring, - let logsManager = DI.inject(SDKLogsManagerProtocol.self) else { + guard let monitoring = monitoring else { return } + guard let logsManager = DI.inject(SDKLogsManagerProtocol.self) else { + Logger.common(message: "[SDKLogs] Unable to send monitoring logs: SDKLogsManager is not registered in DI", level: .error, category: .general) return } + Logger.common(message: "[SDKLogs] Monitoring config contains \(monitoring.logs.elements.count) log request(s)", level: .debug, category: .general) logsManager.sendLogs(logs: monitoring.logs.elements) } diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 960ad9b93..a5587a393 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -32,7 +32,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { + guard URLValidator.isValidHostWithOptionalPath(HostNormalizer.extractHost(value)) else { return .rejected(value) } diff --git a/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift b/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift index e27d9eb36..58b820e47 100644 --- a/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift +++ b/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift @@ -70,6 +70,7 @@ final class InappSessionManager: InappSessionManagerProtocol { let timeBetweenVisitsSeconds = now.timeIntervalSince(lastTimestamp) if timeBetweenVisitsSeconds > Double(sessionTimeInSeconds) { updatingInappSession = true + Logger.common(message: "──────────────── [New session] ────────────────", level: .info, category: .general) Logger.common(message: "[InappSessionManager] Session expired. Need to update session...") updateInappSession() } else { diff --git a/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift b/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift index 6b619a130..d7ed39172 100644 --- a/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift +++ b/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift @@ -13,7 +13,7 @@ struct Monitoring: Decodable, Equatable { struct Logs: Decodable, Equatable { let requestId: String - let deviceUUID: String + let target: String let from: String let to: String } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index ed73d63dd..cfcdd8681 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -27,8 +27,9 @@ 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 operationsDomain: Optional host for sending operations, optionally with + /// a path prefix (e.g. `domain.com/api/v2`) — operation endpoints are appended after it. + /// 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`. @@ -63,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard URLValidator.isValidHost(HostNormalizer.extractHost(operationsDomain)) else { + guard URLValidator.isValidHostWithOptionalPath(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/MindboxLogger/MD5Hash.swift b/Mindbox/MindboxLogger/MD5Hash.swift new file mode 100644 index 000000000..cf14c9e10 --- /dev/null +++ b/Mindbox/MindboxLogger/MD5Hash.swift @@ -0,0 +1,51 @@ +// +// MD5Hash.swift +// Mindbox +// +// Created by Sergei Semko on 8/3/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import CommonCrypto +// CryptoKit is auto-weak-linked (deployment target < iOS 13, LC_LOAD_WEAK_DYLIB +// in the linked binary), so this import cannot break iOS 12 startup; the +// #available guard below keeps its symbols untouched there. +import CryptoKit +import Foundation + +/// Match token for monitoring log requests: MD5 hex of the lowercased deviceUUID. +/// Both the hash input and the hex are lowercased, so comparison is case-insensitive. +/// Must stay in sync with the server contract and the Android SDK. +struct MD5Hash: Equatable { + let hex: String + + init(hex: String) { + self.hex = hex.lowercased() + } + + init(deviceUUID: String) { + self.hex = Self.md5HexDigest(of: deviceUUID.lowercased()) + } + + private static func md5HexDigest(of string: String) -> String { + let data = Data(string.utf8) + let digest: [UInt8] + if #available(iOS 13.0, *) { + digest = Array(Insecure.MD5.hash(data: data)) + } else { + digest = commonCryptoMD5(data) + } + return digest.map { String(format: "%02x", $0) }.joined() + } + + // Internal, not private: no iOS 12 simulator exists on modern Xcode, so tests + // call this fallback directly — CC_MD5 is the same C API on every OS version. + @available(iOS, introduced: 12.0, deprecated: 13.0, message: "CC_MD5 fallback; delete when the deployment target reaches iOS 13") + static func commonCryptoMD5(_ data: Data) -> [UInt8] { + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + data.withUnsafeBytes { bytes in + _ = CC_MD5(bytes.baseAddress, CC_LONG(data.count), &digest) + } + return digest + } +} diff --git a/Mindbox/MindboxLogger/SDKLogsManager.swift b/Mindbox/MindboxLogger/SDKLogsManager.swift index 196270ecc..35f18319a 100644 --- a/Mindbox/MindboxLogger/SDKLogsManager.swift +++ b/Mindbox/MindboxLogger/SDKLogsManager.swift @@ -29,20 +29,39 @@ class SDKLogsManager: SDKLogsManagerProtocol { func sendLogs(logs: [Monitoring.Logs]) { guard !logs.isEmpty else { return } + guard let deviceUUID = persistenceStorage.deviceUUID, !deviceUUID.isEmpty else { + Logger.common(message: "[SDKLogs] Skip monitoring logs: deviceUUID is missing", level: .error, category: .general) + return + } + let deviceTarget = MD5Hash(deviceUUID: deviceUUID) var handledLogsRequestIds = persistenceStorage.handledlogRequestIds ?? [] for log in logs { - if !handledLogsRequestIds.contains(log.requestId) && persistenceStorage.deviceUUID == log.deviceUUID.uppercased() { - handledLogsRequestIds.append(log.requestId) - guard let from = log.from.toDate(withFormat: .utc), - let to = log.to.toDate(withFormat: .utc) else { - return - } + guard !handledLogsRequestIds.contains(log.requestId) else { + Logger.common(message: "[SDKLogs] Skip request \(log.requestId): already handled", level: .debug, category: .general) + continue + } + guard deviceTarget == MD5Hash(hex: log.target) else { + Logger.common(message: "[SDKLogs] Skip request \(log.requestId): target \(log.target) doesn't match device target \(deviceTarget.hex)", level: .debug, category: .general) + continue + } + handledLogsRequestIds.append(log.requestId) + guard let from = log.from.toDate(withFormat: .utc), + let to = log.to.toDate(withFormat: .utc) else { + Logger.common(message: "[SDKLogs] Skip request \(log.requestId): malformed dates from: \"\(log.from)\", to: \"\(log.to)\"", level: .error, category: .general) + continue + } - do { - let body = try getBody(from: from, to: to, requestID: log.requestId) - let event = Event(type: .sdkLogs, body: BodyEncoder(encodable: body).body) - eventRepository.send(event: event) { _ in } - } catch {} + do { + let body = try getBody(from: from, to: to, requestID: log.requestId) + let event = Event(type: .sdkLogs, body: BodyEncoder(encodable: body).body) + Logger.common(message: "[SDKLogs] Sending logs for request \(log.requestId), period \(log.from) – \(log.to), status: \(body.status), lines: \(body.content.count)", level: .info, category: .general) + eventRepository.send(event: event) { result in + if case let .failure(error) = result { + Logger.common(message: "[SDKLogs] Sending logs for request \(log.requestId) failed: \(error.localizedDescription)", level: .error, category: .general) + } + } + } catch { + Logger.common(message: "[SDKLogs] Failed to build logs body for request \(log.requestId): \(error.localizedDescription)", level: .error, category: .general) } } diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index ca647a859..0a7edfd70 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -21,6 +21,8 @@ open class OperationResponse: OperationResponseType { public let personalOffers: [PersonalOffersResponse]? public let balances: [BalanceResponse]? public let discountCards: [DiscountCardResponse]? + // TODO: MOBILE-303 — rename to `promoActions` in 3.0: the API key is plural, + // the singular property name is a source-breaking legacy we keep until a major release. public let promoAction: [PromoActionsResponse]? public let retailOrderStatistics: RetailOrderStatisticsResponse? @@ -42,14 +44,40 @@ open class OperationResponse: OperationResponseType { personalOffers = try container.decodeIfPresent([PersonalOffersResponse].self, forKey: .personalOffers) balances = try container.decodeIfPresent([BalanceResponse].self, forKey: .balances) discountCards = try container.decodeIfPresent([DiscountCardResponse].self, forKey: .discountCards) - promoAction = try container.decodeIfPresent([PromoActionsResponse].self, forKey: .promoAction) + promoAction = try container.decodeIfPresent([PromoActionsResponse].self, forKey: .promoActions) retailOrderStatistics = try container.decodeIfPresent(RetailOrderStatisticsResponse.self, forKey: .retailOrderStatistics) } + // `encode(to:)` must stay in sync with `Keys` — the compiler can't synthesize it + // from this enum (it isn't named `CodingKeys`), and synthesized encoding by + // property names is exactly what hid the promoAction/promoActions mismatch. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + try container.encode(status, forKey: .status) + try container.encodeIfPresent(customer, forKey: .customer) + try container.encodeIfPresent(productList, forKey: .productList) + try container.encodeIfPresent(productListItems, forKey: .productListItems) + try container.encodeIfPresent(recommendations, forKey: .recommendations) + try container.encodeIfPresent(customerSegmentations, forKey: .customerSegmentations) + try container.encodeIfPresent(setProductCountInList, forKey: .setProductCountInList) + try container.encodeIfPresent(promoCode, forKey: .promoCode) + try container.encodeIfPresent(personalOffers, forKey: .personalOffers) + try container.encodeIfPresent(balances, forKey: .balances) + try container.encodeIfPresent(discountCards, forKey: .discountCards) + try container.encodeIfPresent(promoAction, forKey: .promoActions) + try container.encodeIfPresent(retailOrderStatistics, forKey: .retailOrderStatistics) + } + enum Keys: String, CodingKey { case status case customer case productList + // Encode-only: both productList shapes decode from the `productList` wire key, + // but have always re-encoded under their own property names — kept that way + // so the bridge payload only gains promoActions, nothing else moves. + // TODO: MOBILE-303 — drop this key in 3.0 and re-encode both shapes under + // `productList`, making the bridge payload fully wire-faithful (as on Android). + case productListItems case recommendations case customerSegmentations case setProductCountInList @@ -57,7 +85,9 @@ open class OperationResponse: OperationResponseType { case personalOffers case balances case discountCards - case promoAction + // The API sends the plural key; the `promoAction` property keeps its + // historical singular name because renaming it would break the public SDK surface. + case promoActions case retailOrderStatistics } } diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index 1dc2320b7..9ef6f8e43 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -9,7 +9,8 @@ import Foundation /// Scheme-aware normalization for `domain` / `operationsDomain` inputs. -/// Accepts `host`, `https://host`, `http://host`, with or without trailing slash. +/// Accepts `host`, `https://host`, `http://host`, optionally followed by a path +/// prefix (`host/api/v2`), with or without trailing slash. The path is preserved. enum HostNormalizer { private static let httpsPrefix = "https://" diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 60ba12625..455ee6bdb 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -48,7 +48,9 @@ struct URLRequestBuilder { throw URLError(.badURL) } - components.path = route.path + // The base may carry a path prefix (anonymizer case: `host/api/mindbox-regular`); + // operation endpoints are appended after it, not instead of it. + components.path += route.path components.queryItems = makeQueryItems(for: route.queryParameters) return components diff --git a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift index 357ca885f..62ceedb78 100644 --- a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift @@ -370,7 +370,7 @@ extension MBPersistenceStorage { } } -/// Reports (issue #705 follow-up) a fallback-then-recovery fingerprint: install state in BOTH the +/// Reports a fallback-then-recovery fingerprint: install state in BOTH the /// `.standard` fallback and the App Group suite. Read-only by design (cleanup deferred to a future /// migration). Runs after re-registration, so it fires on the recovery launch itself and on every /// cold start while the fingerprint persists. diff --git a/Mindbox/SessionManager/MBSessionManager.swift b/Mindbox/SessionManager/MBSessionManager.swift index cc8941611..29dc92ef9 100644 --- a/Mindbox/SessionManager/MBSessionManager.swift +++ b/Mindbox/SessionManager/MBSessionManager.swift @@ -34,6 +34,7 @@ final class MBSessionManager { object: nil, queue: nil ) { [weak self] _ in + Logger.common(message: "········ [Foreground] app did become active ········", level: .info, category: .general) self?.isActive = true } @@ -42,6 +43,7 @@ final class MBSessionManager { object: nil, queue: nil ) { [weak self] _ in + Logger.common(message: "········ [Background] app did enter background ········", level: .info, category: .general) self?.isActive = false } diff --git a/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift b/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift index a54a83cad..de53e8d66 100644 --- a/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift +++ b/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift @@ -33,9 +33,9 @@ class MBUtilitiesFetcher: UtilitiesFetcher { /// (events database + `UserDefaults` suite). /// /// Returns `""` when the host bundle id is missing or the container is unavailable, - /// so the SDK falls back to local storage instead of crashing the host (issue #705). + /// so the SDK falls back to local storage instead of crashing the host. /// Must never trap — not even in Debug: Debug builds on device farms routinely lack a - /// configured App Group, the exact scenario #705 is about. Surfaced as a `.fault` log. + /// configured App Group — the exact scenario this guards. Surfaced as a `.fault` log. var applicationGroupIdentifier: String { guard let hostApplicationName = hostApplicationName else { Logger.common(message: "[MBUtilitiesFetcher] Host application bundle identifier is unavailable", level: .fault, category: .general) diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index eb973f5c1..ef5ef1689 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -20,6 +20,27 @@ enum URLValidator { /// RFC 1035: each label 1..63 chars. private static let maxLabelLength = 63 + /// Path-prefix rule for `isValidHostWithOptionalPath`: zero or more non-empty + /// segments made of unreserved URL characters or complete `%XX` percent-encoded + /// octets. Query, fragment, empty segments, and a bare/incomplete `%` (not + /// followed by two hex digits) do not match — an incomplete escape parses fine + /// here but corrupts (or fails) `URLComponents` at request time, so it must be + /// rejected at validation, not discovered later as a runtime `badURL`. + private static let pathPrefixPattern = "^(?:/(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+)*$" + + /// Validates `host` or `host/path-prefix` (scheme must already be stripped, + /// e.g. via `HostNormalizer.extractHost`). Used for `operationsDomain`, which + /// may carry a path prefix (e.g. `domain.com/api/v2`) — operation endpoints + /// are appended after it. Path segments must be non-empty; query and fragment + /// are rejected. + static func isValidHostWithOptionalPath(_ value: String) -> Bool { + guard let slashIndex = value.firstIndex(of: "/") else { return isValidHost(value) } + let host = String(value[.. Bool { guard !host.isEmpty, host.count <= maxHostLength else { return false } diff --git a/MindboxLogger.podspec b/MindboxLogger.podspec index af7bcd82c..0ae64b211 100644 --- a/MindboxLogger.podspec +++ b/MindboxLogger.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxLogger" - spec.version = "2.15.2" + spec.version = "2.15.3" spec.summary = "SDK for utilities to work with Mindbox" spec.description = "-" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" diff --git a/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift b/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift index 49cd2ee7a..342b1c0dc 100644 --- a/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift +++ b/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift @@ -21,7 +21,7 @@ class MBLoggerUtilitiesFetcher { /// /// `nil` makes `LoggerDatabaseLoader` fall back to the app's local caches store, so the /// logger keeps working instead of being disabled. Must never trap — the SDK must not - /// bring down its host over an unavailable container, on simulator or device (issue #705). + /// bring down its host over an unavailable container, on simulator or device. var applicationGroupIdentifier: String? { guard let hostApplicationName = hostApplicationName else { return nil diff --git a/MindboxLoggerTests/FileManagerStoreURLTests.swift b/MindboxLoggerTests/FileManagerStoreURLTests.swift index 65a918c46..59c388f79 100644 --- a/MindboxLoggerTests/FileManagerStoreURLTests.swift +++ b/MindboxLoggerTests/FileManagerStoreURLTests.swift @@ -10,7 +10,7 @@ import Foundation import CoreData @testable import MindboxLogger -/// Regression coverage for App Group store-URL resolution (issue #705). +/// Regression coverage for App Group store-URL resolution. /// /// Previously `FileManager.storeURL(for:databaseName:)` called `fatalError` when /// the shared container was unavailable, which crashed the host straight through diff --git a/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift b/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift index 37d5e55ae..ba51ac593 100644 --- a/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift +++ b/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift @@ -9,13 +9,13 @@ import Testing import Foundation @testable import MindboxLogger -/// Regression coverage for issue #705: `MBLoggerUtilitiesFetcher.applicationGroupIdentifier` +/// Regression coverage: `MBLoggerUtilitiesFetcher.applicationGroupIdentifier` /// must NEVER trap. It used to `fatalError` when the shared container was unavailable; it now /// returns `nil` so `LoggerDatabaseLoader` falls back to the app's local store. @Suite("MBLoggerUtilitiesFetcher App Group resolution") struct MBLoggerUtilitiesFetcherTests { - /// #705 core invariant: the getter must resolve WITHOUT trapping. It used to `fatalError` + /// Core invariant: the getter must resolve WITHOUT trapping. It used to `fatalError` /// when the shared container was unavailable; a `fatalError` would tear down the test /// runner, so this test reaching its assertion at all proves the trap is gone. @Test diff --git a/MindboxNotifications.podspec b/MindboxNotifications.podspec index 546412def..8dbd74297 100644 --- a/MindboxNotifications.podspec +++ b/MindboxNotifications.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxNotifications" - spec.version = "2.15.2" + spec.version = "2.15.3" spec.summary = "SDK for integration notifications with Mindbox" spec.description = "This library allows you to integrate notifications and transfer them to Mindbox Marketing Cloud" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" diff --git a/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift b/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift index ee8f00a53..3f5bd98ff 100644 --- a/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift +++ b/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift @@ -20,11 +20,14 @@ fileprivate enum MonitoringConfig: String, Configurable { case monitoringLogsTypeError = "MonitoringLogsTypeError" // Type of `logs` is Int instead of FailableDecodableArray case monitoringLogsOneElementError = "MonitoringLogsOneElementError" // Key is `request` instead of `requestId` - case monitoringLogsTwoElementsError = "MonitoringLogsTwoElementsError" // Key is `request` instead of `requestId` and key is `device` instead of `deviceUUID` + case monitoringLogsTwoElementsError = "MonitoringLogsTwoElementsError" // Key is `request` instead of `requestId` and key is `toTest` instead of `to` case monitoringLogsOneElementTypeError = "MonitoringLogsOneElementTypeError" // Type of `requestId` is Int instead of String case monitoringLogsTwoElementsTypeError = "MonitoringLogsTwoElementsTypeError" // Type of `requestId` is Int instead of String and type of `from` is Object instead `String` case monitoringLogsElementsMixedError = "MonitoringLogsElementsMixedError" // Type of `requestId` is Int instead of String and key is `fromTest` instead of `from` + + case monitoringLogsOldDeviceUuidFormat = "MonitoringLogsOldDeviceUuidFormat" // Legacy element with `deviceUUID` instead of `target`, next to a new-format element + case monitoringLogsBothFieldsFormat = "MonitoringLogsBothFieldsFormat" // Element carries both `deviceUUID` and `target` } final class MonitoringConfigParsingTests: XCTestCase { @@ -34,13 +37,15 @@ final class MonitoringConfigParsingTests: XCTestCase { let config = try! MonitoringConfig.configWithMonitoring.getConfig() XCTAssertEqual(config.logs.elements.count, 2) + XCTAssertEqual(config.logs.elements.first?.target, "334db432a8f72f64a89664682f7bc032") + XCTAssertEqual(config.logs.elements.last?.target, "248eccb79da2bbca61c133c59e4a1516") for log in config.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.deviceUUID) - XCTAssertNotNil(log.requestId) - XCTAssertNotNil(log.from) - XCTAssertNotNil(log.to) + XCTAssertFalse(log.target.isEmpty) + XCTAssertFalse(log.requestId.isEmpty) + XCTAssertFalse(log.from.isEmpty) + XCTAssertFalse(log.to.isEmpty) } } } @@ -68,10 +73,10 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config!.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.deviceUUID) - XCTAssertNotNil(log.requestId) - XCTAssertNotNil(log.from) - XCTAssertNotNil(log.to) + XCTAssertFalse(log.target.isEmpty) + XCTAssertFalse(log.requestId.isEmpty) + XCTAssertFalse(log.from.isEmpty) + XCTAssertFalse(log.to.isEmpty) } } } @@ -85,16 +90,16 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config!.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.deviceUUID) - XCTAssertNotNil(log.requestId) - XCTAssertNotNil(log.from) - XCTAssertNotNil(log.to) + XCTAssertFalse(log.target.isEmpty) + XCTAssertFalse(log.requestId.isEmpty) + XCTAssertFalse(log.from.isEmpty) + XCTAssertFalse(log.to.isEmpty) } } } func test_MonitoringConfig_withLogsTwoElementsError_shouldParseSuccessfullyRemainsElements() { - // Key is `request` instead `requestId` and key is `device` instead of `deviceUUID` + // Key is `request` instead of `requestId` and key is `toTest` instead of `to` let config = try? MonitoringConfig.monitoringLogsTwoElementsError.getConfig() XCTAssertNotNil(config?.logs, "Monitoring must be parsed successfully") @@ -116,4 +121,20 @@ final class MonitoringConfigParsingTests: XCTestCase { XCTAssertEqual(config?.logs.elements.count, 0) } + + func test_MonitoringConfig_withOldDeviceUuidFormat_shouldDropLegacyElements() { + // First element has legacy `deviceUUID` instead of `target`, second is new-format + let config = try! MonitoringConfig.monitoringLogsOldDeviceUuidFormat.getConfig() + + XCTAssertEqual(config.logs.elements.count, 1, "Legacy element must be dropped, new-format element must remain") + XCTAssertEqual(config.logs.elements.first?.target, "248eccb79da2bbca61c133c59e4a1516") + } + + func test_MonitoringConfig_withBothFieldsInElement_shouldParseTarget() { + // Element carries both legacy `deviceUUID` and new `target`; `target` is decoded, `deviceUUID` is ignored + let config = try! MonitoringConfig.monitoringLogsBothFieldsFormat.getConfig() + + XCTAssertEqual(config.logs.elements.count, 1) + XCTAssertEqual(config.logs.elements.first?.target, "334db432a8f72f64a89664682f7bc032") + } } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json index 37f540775..51ec836fa 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json index 875e0567d..e1cb5919a 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json index f803ab032..dc192258e 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json index fe6dc90ea..33c8247b5 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json index f6cd928a0..b322c3c51 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json index 540273ed4..620a3f9ec 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json index fe3b063e2..473564961 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json index 3425bc5e1..edb33c36a 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json index 0c0869660..41c44562b 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json index 67d2ce5de..d0bfe9a44 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json index 052ceff8b..ddef5c783 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json new file mode 100644 index 000000000..0b64b4566 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json @@ -0,0 +1,11 @@ +{ + "logs": [ + { + "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", + "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", + "from": "2024-02-12T10:00:00", + "to": "2024-02-14T20:30:00" + } + ] +} diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json index b3876f263..bd8f53e86 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": 1, - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "fromTest": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json index a439ea941..3b51e84b6 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json @@ -2,13 +2,13 @@ "logsTests": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json new file mode 100644 index 000000000..3422af271 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json @@ -0,0 +1,16 @@ +{ + "logs": [ + { + "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", + "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "from": "2024-02-12T10:00:00", + "to": "2024-02-14T20:30:00" + }, + { + "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", + "target": "248eccb79da2bbca61c133c59e4a1516", + "from": "2023-02-10T00:00:00", + "to": "2023-03-01T20:30:00" + } + ] +} diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json index 9e9bbc571..d9f8bf605 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json @@ -2,13 +2,13 @@ "logs": [ { "request": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json index b983b1882..c97b31f22 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": 1, - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json index 0a278d1d3..b7cd749df 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json @@ -2,15 +2,15 @@ "logs": [ { "request": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "device": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", - "to": "2023-03-01T20:30:00" + "toTest": "2023-03-01T20:30:00" } ] } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json index 67956b664..536d8a95d 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": 1, - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": {}, "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index dc3425ccf..83cde0ef7 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -135,6 +135,89 @@ struct MBConfigurationTests { } } + @Test("operationsDomain accepts path prefix") + func operationsDomainAcceptsPathPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular" + ) + #expect(config.operationsDomain == "https://api-v2.letu.ru/api/mindbox-regular") + } + + @Test("operationsDomain accepts bare host with path prefix") + func operationsDomainAcceptsBareHostWithPathPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/api/v2" + ) + #expect(config.operationsDomain == "domain.com/api/v2") + } + + @Test("operationsDomain with query string throws") + func operationsDomainWithQueryThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/api?x=1" + ) + } + } + + @Test("operationsDomain with empty path segment throws") + func operationsDomainWithEmptyPathSegmentThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com//api" + ) + } + } + + @Test("operationsDomain accepts valid percent-encoded octet in path") + func operationsDomainAcceptsValidPercentEncoding() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%20b" + ) + #expect(config.operationsDomain == "domain.com/a%20b") + } + + @Test("operationsDomain with non-hex percent escape throws") + func operationsDomainWithNonHexPercentEscapeThrows() { + // Regression: "%zz" parsed fine at validation but corrupted (or failed) + // URLComponents at request time — must be rejected here instead. + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%zz" + ) + } + } + + @Test("operationsDomain with dangling percent at end of path throws") + func operationsDomainWithDanglingPercentThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%" + ) + } + } + + @Test("domain with path prefix still throws — domain stays host-only") + func domainWithPathPrefixThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "api.mindbox.ru/api/v2") + } + } + // MARK: - Init: previousInstallationId / previousDeviceUUID UUID handling @Test("Valid previousInstallationId UUID is stored") diff --git a/MindboxTests/DI/AppGroupUnavailableTests.swift b/MindboxTests/DI/AppGroupUnavailableTests.swift index 2740848c6..e535e0d12 100644 --- a/MindboxTests/DI/AppGroupUnavailableTests.swift +++ b/MindboxTests/DI/AppGroupUnavailableTests.swift @@ -11,7 +11,7 @@ import CoreData import MindboxLogger @testable import Mindbox -/// #705 core-SDK fallback: an unavailable App Group must degrade to local storage, not crash. +/// Core-SDK fallback: an unavailable App Group must degrade to local storage, not crash. /// Serialized — the cases mutate global `MBInject` / `MBPersistenceStorage.defaults` / `MBPersistentContainer`. @Suite("App Group unavailable — core SDK fallback", .serialized) struct AppGroupUnavailableTests { @@ -25,7 +25,7 @@ struct AppGroupUnavailableTests { func getDeviceUUID(completion: @escaping (String) -> Void) { completion(UUID().uuidString) } } - /// #705: the getter used to `fatalError` on an unavailable container — a trap would tear down + /// The getter used to `fatalError` on an unavailable container — a trap would tear down /// the runner, so simply reaching the assertion proves it's gone. @Test func coreFetcherResolvesWithoutTrapping() { @@ -71,7 +71,7 @@ struct AppGroupUnavailableTests { } } -/// Storage-transition reporter (#705 follow-up): reports only when install state is in BOTH stores; read-only. +/// Storage-transition reporter: reports only when install state is in BOTH stores; read-only. @Suite("App Group storage-transition reporter") struct AppGroupStorageTransitionReporterTests { diff --git a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift index ba406fa24..cc3e80fd6 100644 --- a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift +++ b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift @@ -13,7 +13,7 @@ struct TransparentViewSyncOperationResponseTests { private let action = "syncOperation" private let requestId = UUID() - // MARK: - HTTP 200 + ValidationError body → .response with raw body (regression: MOBILE-164) + // MARK: - HTTP 200 + ValidationError body → .response with raw body (regression) @Test("HTTP 200 ValidationError body becomes .response with raw body string") func validationErrorBody_becomesResponseWithRawBody() throws { @@ -118,7 +118,7 @@ struct TransparentViewSyncOperationResponseTests { } } - // MARK: - Failure payloads: data contents only, no {type, data} envelope (MOBILE-197) + // MARK: - Failure payloads: data contents only, no {type, data} envelope private func decodedErrorPayload(_ outgoing: BridgeMessage) throws -> [String: Any] { #expect(outgoing.type == .error) diff --git a/MindboxTests/MindboxLogger/MD5HashTests.swift b/MindboxTests/MindboxLogger/MD5HashTests.swift new file mode 100644 index 000000000..0a0711d5a --- /dev/null +++ b/MindboxTests/MindboxLogger/MD5HashTests.swift @@ -0,0 +1,53 @@ +// +// MD5HashTests.swift +// MindboxTests +// +// Created by Sergei Semko on 8/3/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +// Shared cross-platform vectors: the same deviceUUID must produce the same target +// on iOS, Android and the server. +private let sharedMD5Vectors: [(String, String)] = [ + ("216e6225-3170-4089-a6f0-3d1ed8f64153", "334db432a8f72f64a89664682f7bc032"), + // the same uuid uppercased → the same hash: MD5Hash lowercases the input before hashing + // (raw md5 of the UPPERCASE string itself would be 2a482810731225da19199c17a956a560) + ("216E6225-3170-4089-A6F0-3D1ED8F64153", "334db432a8f72f64a89664682f7bc032"), + ("126e6225-3170-4089-a6f0-3d1ed8f64153", "248eccb79da2bbca61c133c59e4a1516"), + // hash with leading zeros — catches broken hex padding + ("7e570ddf-8270-40a8-a369-b584ff5e9ff0", "000baa91b37b3c201e3f8604c7845201"), + // md5 of zero bytes — the well-known constant + ("", "d41d8cd98f00b204e9800998ecf8427e") +] + +@Suite("MD5Hash") +struct MD5HashTests { + + @Test(arguments: sharedMD5Vectors) + func matchesSharedDeviceUUIDVectors(deviceUUID: String, expectedHex: String) { + #expect(MD5Hash(deviceUUID: deviceUUID) == MD5Hash(hex: expectedHex)) + } + + @Test + func normalizesHexCaseForComparison() { + #expect(MD5Hash(hex: "334DB432A8F72F64A89664682F7BC032") == MD5Hash(hex: "334db432a8f72f64a89664682f7bc032")) + } + + @Test + func differentUUIDsProduceDifferentHashes() { + #expect(MD5Hash(deviceUUID: "216e6225-3170-4089-a6f0-3d1ed8f64153") != MD5Hash(deviceUUID: "126e6225-3170-4089-a6f0-3d1ed8f64153")) + } + + // Deprecated-annotated so the CC_MD5 fallback call compiles without a warning. + @available(iOS, introduced: 12.0, deprecated: 13.0, message: "Exercises the iOS 12 CC_MD5 fallback") + @Test(arguments: sharedMD5Vectors) + func commonCryptoFallbackMatchesSharedVectors(deviceUUID: String, expectedHex: String) { + let digest = MD5Hash.commonCryptoMD5(Data(deviceUUID.lowercased().utf8)) + let hex = digest.map { String(format: "%02x", $0) }.joined() + #expect(hex == expectedHex) + } +} diff --git a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift index 378fff2fa..13067c49b 100644 --- a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift +++ b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift @@ -12,6 +12,14 @@ import XCTest final class SDKLogManagerTests: XCTestCase { + // Shared cross-platform vectors: target is the MD5 hex of the lowercased deviceUUID. + private enum Stub { + static let deviceUUID = "216E6225-3170-4089-A6F0-3D1ED8F64153" // persisted uppercased, as the real SDK stores it + static let target = "334db432a8f72f64a89664682f7bc032" + static let foreignTarget = "248eccb79da2bbca61c133c59e4a1516" // another device's target + static let emptyStringTarget = "d41d8cd98f00b204e9800998ecf8427e" // md5 of "" + } + var eventRepositoryMock: EventRepositoryMock! var logsManager: SDKLogsManager! var persistenceStorageMock: PersistenceStorage! @@ -19,7 +27,7 @@ final class SDKLogManagerTests: XCTestCase { override func setUp() { super.setUp() persistenceStorageMock = DI.injectOrFail(PersistenceStorage.self) - persistenceStorageMock.deviceUUID = "2" + persistenceStorageMock.deviceUUID = Stub.deviceUUID eventRepositoryMock = DI.injectOrFail(EventRepositoryMock.self) logsManager = DI.injectOrFail(SDKLogsManagerProtocol.self) as? SDKLogsManager } @@ -31,34 +39,75 @@ final class SDKLogManagerTests: XCTestCase { super.tearDown() } - func testBody_withWrongUUID_shouldReturnNil() { - let dateFrom = Date().addingTimeInterval(-60) - let dateTo = Date() - let logs: [Monitoring.Logs] = [ - .init(requestId: "1", - deviceUUID: "2", - from: dateFrom.toString(withFormat: .utc), - to: dateTo.toString(withFormat: .utc)) - ] - persistenceStorageMock.deviceUUID = "3" - logsManager.sendLogs(logs: logs) + func testBody_withMatchingTarget_shouldSendLogs() { + logsManager.sendLogs(logs: [makeLog(target: Stub.target)]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 1) + } + + func testBody_withForeignTarget_shouldNotSendLogs() { + logsManager.sendLogs(logs: [makeLog(target: Stub.foreignTarget)]) XCTAssertNil(eventRepositoryMock.lastBody) XCTAssertEqual(eventRepositoryMock.requests.count, 0) } + func testBody_withUppercasedTargetHex_shouldSendLogs() { + logsManager.sendLogs(logs: [makeLog(target: Stub.target.uppercased())]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 1) + } + func testBody_withRepeatedRequestID_shouldReturnOneRequest() { - let dateFrom = Date().addingTimeInterval(-60).toString(withFormat: .utc) - let dateTo = Date().toString(withFormat: .utc) - let logs: [Monitoring.Logs] = [ - .init(requestId: "1", deviceUUID: "2", from: dateFrom, to: dateTo), - .init(requestId: "1", deviceUUID: "2", from: dateFrom, to: dateTo) + let logs = [ + makeLog(requestId: "1", target: Stub.target), + makeLog(requestId: "1", target: Stub.target) ] logsManager.sendLogs(logs: logs) + XCTAssertEqual(eventRepositoryMock.requests.count, 1) } + func testBody_withNilDeviceUUID_shouldNotSendLogs() { + persistenceStorageMock.deviceUUID = nil + let logs = [ + makeLog(requestId: "1", target: Stub.target), + makeLog(requestId: "2", target: Stub.emptyStringTarget) + ] + + logsManager.sendLogs(logs: logs) + + XCTAssertEqual(eventRepositoryMock.requests.count, 0) + } + + func testBody_withEmptyDeviceUUID_shouldNotSendLogs() { + // A blank persisted deviceUUID must match nothing, even a config carrying md5(""). + persistenceStorageMock.deviceUUID = "" + logsManager.sendLogs(logs: [makeLog(target: Stub.emptyStringTarget)]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 0) + } + + func testBody_withEmptyTarget_shouldNotSendLogs() { + // Parity with Android's validator: a blank target must never match. + logsManager.sendLogs(logs: [makeLog(target: "")]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 0) + } + + func testBody_withMalformedDatesEntry_shouldStillProcessSubsequentEntries() { + let logs = [ + Monitoring.Logs(requestId: "1", target: Stub.target, from: "not-a-date", to: "not-a-date"), + makeLog(requestId: "2", target: Stub.target) + ] + + logsManager.sendLogs(logs: logs) + + XCTAssertEqual(eventRepositoryMock.requests.count, 1, "The valid entry after the malformed one must still be sent") + XCTAssertEqual(persistenceStorageMock.handledlogRequestIds, ["1", "2"], "Both entries must be marked handled and persisted") + } + func test_status_shouldReturnOk() throws { let dateFrom = Date().addingTimeInterval(-60) let dateTo = Date() @@ -126,4 +175,14 @@ final class SDKLogManagerTests: XCTestCase { XCTAssertEqual(actualLogs.count, 1) XCTAssertEqual(actualLogs, expectedResult) } + + private func makeLog(requestId: String = "1", + target: String, + from: Date = Date().addingTimeInterval(-60), + to: Date = Date()) -> Monitoring.Logs { + .init(requestId: requestId, + target: target, + from: from.toString(withFormat: .utc), + to: to.toString(withFormat: .utc)) + } } diff --git a/MindboxTests/MindboxOperationsTests.swift b/MindboxTests/MindboxOperationsTests.swift index ac7993748..f141fbeff 100644 --- a/MindboxTests/MindboxOperationsTests.swift +++ b/MindboxTests/MindboxOperationsTests.swift @@ -10,7 +10,7 @@ import Testing import Foundation @testable import Mindbox -/// Contract tests for the public operations pipeline (MOBILE-208): the heavy work +/// Contract tests for the public operations pipeline: the heavy work /// runs on the serial eventQueue, yet every externally observable guarantee holds — /// call order equals DB write order, the body is snapshotted at call time, invalid /// input is dropped before the hop, executeSyncOperation completions always arrive diff --git a/MindboxTests/MindboxTests.swift b/MindboxTests/MindboxTests.swift index 0a8acc4ce..903a6b3f3 100644 --- a/MindboxTests/MindboxTests.swift +++ b/MindboxTests/MindboxTests.swift @@ -191,7 +191,7 @@ class MindboxTests: XCTestCase { // Functional validator cases live in OperationNameValidatorTests (Swift Testing). - // Regression (MOBILE-208): observe() used to invoke the host completion while + // Regression: observe() used to invoke the host completion while // holding observeSemaphore, so re-entering getDeviceUUID/getAPNSToken from inside // a completion deadlocked the controller queue. Now the completion runs after the // lock is released; on the old code this test fails by expectation timeout. diff --git a/MindboxTests/Network/MBEventRepositorySendRawTests.swift b/MindboxTests/Network/MBEventRepositorySendRawTests.swift index 4f22816b4..40fc48818 100644 --- a/MindboxTests/Network/MBEventRepositorySendRawTests.swift +++ b/MindboxTests/Network/MBEventRepositorySendRawTests.swift @@ -219,7 +219,7 @@ struct MBEventRepositorySendRawTests { #expect(isMain) } - // MARK: - send: completion always on the main queue (MOBILE-208 contract) + // MARK: - send: completion always on the main queue (public contract) @Test("send network-path completion is delivered on the main queue") func sendTyped_completion_onMainQueue() async throws { diff --git a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift index 10c750257..ec0cd5ae2 100644 --- a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift +++ b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift @@ -639,7 +639,7 @@ final class MBNetworkFetcherResponseHandlingTests: XCTestCase { waitForExpectations(timeout: 1) } - // MARK: - requestRaw: HTTP 200 + ValidationError body → raw Data success (regression: MOBILE-164) + // MARK: - requestRaw: HTTP 200 + ValidationError body → raw Data success (regression) func test_requestRaw_http200_statusValidationError_returnsRawData() throws { let fetcher = try makeFetcher() diff --git a/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift new file mode 100644 index 000000000..b3e6249ce --- /dev/null +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -0,0 +1,210 @@ +// +// OperationResponseTests.swift +// MindboxTests +// +// Created by Sergei Semko on 16.07.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@testable import Mindbox + +/// Wire-contract tests for `OperationResponse`: the API returns promo +/// actions under the plural `promoActions` key, and the JSON re-encoded for the +/// hybrid bridges (`createJSON`) must use the same wire keys as the decoder — +/// otherwise a field silently vanishes between the API and the JS layer. +@Suite("OperationResponse wire contract", .tags(.decoding, .customOperation)) +struct OperationResponseTests { + + /// Response shaped like the documented `get-promotions-for-customer` payload. + private static let responseJSON = Data(""" + { + "status": "Success", + "promoActions": [ + { + "ids": { "externalId": "summer-sale" }, + "name": "Summer sale", + "description": "10% off everything", + "startDateTimeUtc": "2026-06-01T00:00:00Z", + "endDateTimeUtc": "2026-08-31T23:59:59Z", + "customFields": { "testCustomField": "value" }, + "limits": [ + { + "type": "personalLimit", + "untilDateTimeUtc": "2026-08-31T23:59:59Z", + "amount": { "type": "absolute", "value": 3 }, + "used": { "amount": 1 } + } + ] + }, + { "name": "Second action" } + ], + "promoCode": { "isUsed": false }, + "balances": [ { "total": 100, "available": 90 } ], + "discountCards": [ { "ids": { "number": "1234" } } ] + } + """.utf8) + + private func decodeResponse() throws -> OperationResponse { + try JSONDecoder().decode(OperationResponse.self, from: Self.responseJSON) + } + + private func reEncodedDictionary(of response: OperationResponse) throws -> [String: Any] { + let json = try #require(response.createJSON().data(using: .utf8)) + return try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + } + + @Test("Promo actions are decoded from the plural promoActions wire key") + func decodesPromoActionsFromPluralKey() throws { + let response = try decodeResponse() + + let actions = try #require(response.promoAction, + "the API sends promoActions (plural); the array must not be silently dropped") + try #require(actions.count == 2) + #expect(actions[0].name == "Summer sale") + #expect(actions[0].description == "10% off everything") + #expect(actions[0].ids?["externalId"] == "summer-sale") + #expect(actions[0].startDateTimeUtc != nil) + #expect(actions[0].endDateTimeUtc != nil) + #expect(actions[0].limits?.count == 1) + #expect(actions[1].name == "Second action") + } + + @Test("createJSON re-encodes promo actions under the promoActions wire key") + func createJSONKeepsPluralWireKey() throws { + let response = try decodeResponse() + + let dict = try reEncodedDictionary(of: response) + + #expect(!dict.keys.contains("promoAction"), + "the singular key never existed on the wire and must not leak into re-encoded JSON") + let actions = try #require(dict["promoActions"] as? [[String: Any]]) + try #require(actions.count == 2) + #expect(actions[0]["name"] as? String == "Summer sale") + #expect(actions[1]["name"] as? String == "Second action") + } + + @Test("createJSON keeps every other wire key unchanged") + func createJSONKeepsOtherWireKeys() throws { + let response = try decodeResponse() + + let dict = try reEncodedDictionary(of: response) + + #expect(Set(dict.keys) == ["status", "promoActions", "promoCode", "balances", "discountCards"]) + } + + // The two productList shapes share one wire key on decode, but have always + // re-encoded under their own property names (synthesized behavior). Locked + // down here so the encode(to:) rewrite changes nothing but promoActions. + @Test("An array productList re-encodes under the productList key") + func productListArrayKeepsItsKey() throws { + let response = try JSONDecoder().decode( + OperationResponse.self, + from: Data(#"{"status": "Success", "productList": [{"count": 2, "price": 100}]}"#.utf8)) + + try #require(response.productList?.count == 1) + let dict = try reEncodedDictionary(of: response) + #expect((dict["productList"] as? [[String: Any]])?.count == 1) + #expect(!dict.keys.contains("productListItems")) + } + + @Test("An object productList re-encodes under the productListItems key") + func productListObjectKeepsItemsKey() throws { + let response = try JSONDecoder().decode( + OperationResponse.self, + from: Data(#"{"status": "Success", "productList": {"items": [{"priceForCustomer": 5}]}}"#.utf8)) + + try #require(response.productListItems?.items?.count == 1) + let dict = try reEncodedDictionary(of: response) + #expect((dict["productListItems"] as? [String: Any]) != nil) + #expect(!dict.keys.contains("productList")) + } + + /// Shape of a production sync-operation response from a client report, fully + /// anonymized: fractional seconds longer than the `.SSS` parse pattern (6 and 5 + /// digits), sibling non-Utc date keys, `timeZoneMode`, and custom fields mixing + /// booleans with strings. All values are synthetic; the key set and the date + /// string formats are what mirror the real payload. + private static let productionShapedJSON = Data(""" + { + "status": "Success", + "promoActions": [ + { + "ids": { "externalId": "promo-12345678" }, + "description": "First promo description", + "endDateTime": "2026-12-31T21:00:00Z", + "endDateTimeUtc": "2026-12-31T21:00:00Z", + "name": "First promo", + "startDateTime": "2026-01-02T03:04:05.256574Z", + "startDateTimeUtc": "2026-01-02T03:04:05.256574Z", + "timeZoneMode": "project", + "customFields": { + "offerEnabled": true, + "offerPlacement": "top", + "imageUrl": "https://example.com/promo-1.png", + "inAppOperation": "Mobile.SomeOperation", + "modalVariant": "text", + "buttonText": "Accept", + "actionUrl": "https://example.com", + "formId": "1" + } + }, + { + "ids": { "externalId": "promo-87654321" }, + "description": "Second promo description", + "endDateTime": "2026-12-31T21:00:00Z", + "endDateTimeUtc": "2026-12-31T21:00:00Z", + "name": "Second promo", + "startDateTime": "2026-01-02T03:04:06.94785Z", + "startDateTimeUtc": "2026-01-02T03:04:06.94785Z", + "timeZoneMode": "project", + "customFields": { + "offerEnabled": true, + "offerPlacement": "top", + "imageUrl": "https://example.com/promo-2.webp", + "inAppOperation": "Mobile.SomeOperation", + "modalVariant": "image", + "modalImageUrl": "https://example.com/modal-2.jpg", + "buttonText": "Get two", + "actionUrl": "https://example.com", + "formId": "2" + } + } + ] + } + """.utf8) + + @Test("A production-shaped payload decodes and survives re-encoding") + func decodesProductionShapedPayload() throws { + let response = try JSONDecoder().decode(OperationResponse.self, from: Self.productionShapedJSON) + + let actions = try #require(response.promoAction) + try #require(actions.count == 2) + #expect(actions[0].name == "First promo") + #expect(actions[0].ids?["externalId"] == "promo-12345678") + + // ICU truncates fractional seconds beyond 3 digits on parse (.256574 → .256); + // a regression to "N digits = N milliseconds" would shift this by ~4 minutes. + let reference = try #require(ISO8601DateFormatter().date(from: "2026-01-02T03:04:05Z")) + let start = try #require(actions[0].startDateTimeUtc).date + #expect(abs(start.timeIntervalSince(reference)) < 1.0) + + let dict = try reEncodedDictionary(of: response) + let encodedActions = try #require(dict["promoActions"] as? [[String: Any]]) + try #require(encodedActions.count == 2) + // CustomFields must round-trip JSON types untouched — booleans stay booleans. + let customFields = try #require(encodedActions[0]["customFields"] as? [String: Any]) + #expect(customFields["offerEnabled"] as? Bool == true) + #expect(customFields["offerPlacement"] as? String == "top") + #expect(customFields["formId"] as? String == "1") + } + + @Test("A response without promo actions decodes with a nil field and no error") + func decodesWithoutPromoActions() throws { + let response = try JSONDecoder().decode(OperationResponse.self, + from: Data(#"{"status": "Success"}"#.utf8)) + #expect(response.promoAction == nil) + #expect(response.status == .success) + } +} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 230b9a85a..b38093203 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -164,6 +164,57 @@ struct OperationsURLRoutingTests { #expect(url?.path == "/v3/operations/async") } + // MARK: - Path prefix in operationsDomain + + @Test("operationsDomain with path prefix appends operation endpoint after the prefix") + func pathPrefixAppendsEndpointAfterPrefix() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api-v2.letu.ru") + #expect(url?.path == "/api/mindbox-regular/v3/operations/async") + } + + @Test("Sync operations route keeps the path prefix") + func pathPrefixKeptOnSyncRoute() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) + + let url = try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url + #expect(url?.path == "/api/mindbox-regular/v3/operations/sync") + } + + @Test("Bare host with path prefix gets default https:// and keeps the prefix") + func barePathPrefixUsesHttps() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "domain.com/api/v2") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "domain.com") + #expect(url?.path == "/api/v2/v3/operations/async") + } + + @Test("Trailing slash after path prefix does not produce a double slash") + func trailingSlashAfterPathPrefixStripped() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "domain.com/api/v2/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/api/v2/v3/operations/async") + } + + @Test("Config and geo routes are unaffected by operations path prefix") + func domainRoutesUnaffectedByPathPrefix() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + + let geoURL = try builder.asURLRequest(route: FetchInAppGeoRoute()).url + #expect(geoURL?.host == domain) + #expect(geoURL?.path == "/geo") + } + @Test("Fails fast when base URL is unparseable (no silent relative-URL request)") func failsFastOnUnparseableBaseURL() { // Embedded space defeats both `URLComponents(string:)` parsing and @@ -364,6 +415,74 @@ struct OperationsURLRoutingTests { ) } + @Test("Policy — saves value with path prefix in canonical form") + func policySavesPathPrefixValue() { + #expect( + OperationsDomainConfigPolicy.action(for: "api-v2.letu.ru/api/mindbox-regular", currentlyStored: nil) + == .save("https://api-v2.letu.ru/api/mindbox-regular") + ) + } + + @Test("Policy — normalizes trailing slash after path prefix") + func policyNormalizesTrailingSlashAfterPathPrefix() { + #expect( + OperationsDomainConfigPolicy.action(for: "https://x.ru/api/v2/", currentlyStored: nil) + == .save("https://x.ru/api/v2") + ) + } + + @Test("Policy — keeps when canonical path-prefix form equals stored") + func policyKeepsOnMatchingPathPrefix() { + #expect( + OperationsDomainConfigPolicy.action( + for: "x.ru/api/v2/", + currentlyStored: "https://x.ru/api/v2" + ) == .keep + ) + } + + @Test("Policy — rejects path with query string") + func policyRejectsPathWithQuery() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/api?x=1", currentlyStored: "https://good.ru") + == .rejected("x.ru/api?x=1") + ) + } + + @Test("Policy — rejects empty path segment") + func policyRejectsEmptyPathSegment() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru//api", currentlyStored: "https://good.ru") + == .rejected("x.ru//api") + ) + } + + @Test("Policy — accepts valid percent-encoded octet in path") + func policyAcceptsValidPercentEncoding() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%20b", currentlyStored: nil) + == .save("https://x.ru/a%20b") + ) + } + + @Test("Policy — rejects non-hex percent escape from config") + func policyRejectsNonHexPercentEscape() { + // Regression: "%zz" is not a valid percent-encoded octet — it would corrupt + // (or fail to build) the request URL at runtime if accepted here. + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%zz", currentlyStored: "https://good.ru") + == .rejected("x.ru/a%zz") + ) + } + + @Test("Policy — rejects dangling percent at end of path from config") + func policyRejectsDanglingPercent() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%", currentlyStored: "https://good.ru") + == .rejected("x.ru/a%") + ) + } + // MARK: - Persistence lifecycle @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") diff --git a/SDKVersionProvider/SDKVersionConfig.xcconfig b/SDKVersionProvider/SDKVersionConfig.xcconfig index d166653e1..60e5ab7a0 100644 --- a/SDKVersionProvider/SDKVersionConfig.xcconfig +++ b/SDKVersionProvider/SDKVersionConfig.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 2.15.2 +MARKETING_VERSION = 2.15.3 diff --git a/SDKVersionProvider/SDKVersionProvider.swift b/SDKVersionProvider/SDKVersionProvider.swift index 7f33b151d..146d499e5 100644 --- a/SDKVersionProvider/SDKVersionProvider.swift +++ b/SDKVersionProvider/SDKVersionProvider.swift @@ -8,6 +8,6 @@ import Foundation public class SDKVersionProvider { - public static let sdkVersion = "2.15.2" + public static let sdkVersion = "2.15.3" }