From 29a93c1f9c4bc32a43eae2e03d3d8d501d603f16 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 21 Jul 2026 17:12:23 +0300 Subject: [PATCH] MOBILE-258: Allow path prefix for operaionsDomain --- .../mobile_sdk/MindboxConfiguration.kt | 4 +- .../cloud/mindbox/mobile_sdk/SdkValidation.kt | 20 ++++- .../OperationsDomainConfigPolicy.kt | 2 +- .../mobile_sdk/SdkValidationDomainTest.kt | 87 +++++++++++++++++++ .../OperationsDomainConfigPolicyTest.kt | 46 ++++++++-- .../mobile_sdk/managers/GatewayManagerTest.kt | 31 +++++++ 6 files changed, 182 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt index b6c10f98..1be09858 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt @@ -158,7 +158,9 @@ public class MindboxConfiguration private constructor( /** * Optional host for operations (/v3/operations/async, /v3/operations/sync, * /v1.1/customer/mobile-track-visit). Use when your project routes operations through - * an anonymizer proxy. A blank value is treated as not set. An invalid value is logged + * an anonymizer proxy. The value may include a path prefix + * (e.g. "domain.com/api/v2") — operation endpoints are appended after it. + * A blank value is treated as not set. An invalid value is logged * and ignored during SDK initialization. */ public fun operationsDomain(operationsDomain: String): Builder { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt index ed2f7f34..afd49ac2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt @@ -53,6 +53,24 @@ internal object SdkValidation { return host.isNotBlank() && isDomainValid(host) } + /** + * Path-prefix rule for [isValidOperationsDomain]: zero or more non-empty segments of + * unreserved URL characters. Query, fragment and empty segments do not match. + */ + private val PATH_PREFIX_REGEX = Regex("^(?:/[A-Za-z0-9._~%-]+)*$") + + /** + * Returns true if [value] is a valid operations domain: a host optionally followed by + * a path prefix (e.g. "domain.com/api/v2"). Accepts optional http:// or https:// prefix + * and optional trailing slash. Query, fragment and empty path segments are rejected. + */ + fun isValidOperationsDomain(value: String): Boolean { + val hostAndPath = extractHost(value) + val host = hostAndPath.substringBefore('/') + if (host.isBlank() || !isDomainValid(host)) return false + return PATH_PREFIX_REGEX.matches(hostAndPath.removePrefix(host)) + } + fun validateConfiguration( domain: String, endpointId: String, @@ -84,7 +102,7 @@ internal object SdkValidation { add(Error.INVALID_INSTALLATION_ID) } - if (operationsDomain != null && !isValidDomain(operationsDomain)) { + if (operationsDomain != null && !isValidOperationsDomain(operationsDomain)) { add(Error.INVALID_OPERATIONS_DOMAIN) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt index 87d55f28..265ae19a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt @@ -21,7 +21,7 @@ internal fun operationsDomainConfigPolicyAction( OperationsDomainConfigPolicyAction.Keep } - if (!SdkValidation.isValidDomain(value)) return OperationsDomainConfigPolicyAction.Keep + if (!SdkValidation.isValidOperationsDomain(value)) return OperationsDomainConfigPolicyAction.Keep return if (value == currentlyStored) { OperationsDomainConfigPolicyAction.Keep diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt index 1761acb9..f4b0239b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt @@ -81,6 +81,24 @@ class SdkValidationDomainTest { assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl(" https://api.mindbox.ru ")) } + @Test + fun `toBaseUrl preserves path prefix when no scheme`() { + assertEquals("https://domain.com/api/v2", SdkValidation.toBaseUrl("domain.com/api/v2")) + } + + @Test + fun `toBaseUrl preserves path prefix and scheme`() { + assertEquals( + "https://api-v2.letu.ru/api/mindbox-regular", + SdkValidation.toBaseUrl("https://api-v2.letu.ru/api/mindbox-regular") + ) + } + + @Test + fun `toBaseUrl strips trailing slash after path prefix`() { + assertEquals("https://domain.com/api/v2", SdkValidation.toBaseUrl("https://domain.com/api/v2/")) + } + // endregion // region isValidDomain @@ -115,5 +133,74 @@ class SdkValidationDomainTest { assertEquals(false, SdkValidation.isValidDomain("not a domain")) } + @Test + fun `isValidDomain rejects host with path prefix — domain stays host-only`() { + assertEquals(false, SdkValidation.isValidDomain("api.mindbox.ru/api/v2")) + } + + // endregion + + // region isValidOperationsDomain + + @Test + fun `isValidOperationsDomain accepts bare host`() { + assertEquals(true, SdkValidation.isValidOperationsDomain("api.mindbox.ru")) + } + + @Test + fun `isValidOperationsDomain accepts https scheme with trailing slash`() { + assertEquals(true, SdkValidation.isValidOperationsDomain("https://api.mindbox.ru/")) + } + + @Test + fun `isValidOperationsDomain accepts host with path prefix`() { + assertEquals(true, SdkValidation.isValidOperationsDomain("domain.com/api/v2")) + } + + @Test + fun `isValidOperationsDomain accepts https scheme with path prefix`() { + assertEquals(true, SdkValidation.isValidOperationsDomain("https://api-v2.letu.ru/api/mindbox-regular")) + } + + @Test + fun `isValidOperationsDomain accepts path prefix with trailing slash`() { + assertEquals(true, SdkValidation.isValidOperationsDomain("domain.com/api/v2/")) + } + + @Test + fun `isValidOperationsDomain rejects blank string`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("")) + } + + @Test + fun `isValidOperationsDomain rejects invalid host with path`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("not a host/api")) + } + + @Test + fun `isValidOperationsDomain rejects empty path segment`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("domain.com//api")) + } + + @Test + fun `isValidOperationsDomain rejects query string`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("domain.com/api?x=1")) + } + + @Test + fun `isValidOperationsDomain rejects fragment`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("domain.com/api#section")) + } + + @Test + fun `isValidOperationsDomain rejects path segment with space`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("domain.com/api v2")) + } + + @Test + fun `isValidOperationsDomain rejects path-only value`() { + assertEquals(false, SdkValidation.isValidOperationsDomain("/api/v2")) + } + // endregion } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt index 5fbb7df6..81d39f22 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt @@ -14,11 +14,12 @@ class OperationsDomainConfigPolicyTest { @Before fun setUp() { mockkObject(SdkValidation) - every { SdkValidation.isValidDomain(any()) } returns false - every { SdkValidation.isValidDomain(VALID_HOST) } returns true - every { SdkValidation.isValidDomain(VALID_HOST_WITH_SCHEME) } returns true - every { SdkValidation.isValidDomain(VALID_HOST_WITH_TRAILING_SLASH) } returns true - every { SdkValidation.isValidDomain(ANOTHER_VALID_HOST) } returns true + every { SdkValidation.isValidOperationsDomain(any()) } returns false + every { SdkValidation.isValidOperationsDomain(VALID_HOST) } returns true + every { SdkValidation.isValidOperationsDomain(VALID_HOST_WITH_SCHEME) } returns true + every { SdkValidation.isValidOperationsDomain(VALID_HOST_WITH_TRAILING_SLASH) } returns true + every { SdkValidation.isValidOperationsDomain(ANOTHER_VALID_HOST) } returns true + every { SdkValidation.isValidOperationsDomain(VALID_HOST_WITH_PATH) } returns true } @After @@ -183,10 +184,45 @@ class OperationsDomainConfigPolicyTest { // endregion + // region path prefix — MOBILE-258: operationsDomain may carry a path + + @Test + fun `raw valid host with path prefix no stored value returns Save`() { + val result = operationsDomainConfigPolicyAction( + raw = VALID_HOST_WITH_PATH, + currentlyStored = null + ) + + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST_WITH_PATH), result) + } + + @Test + fun `raw valid host with path prefix same as stored returns Keep`() { + val result = operationsDomainConfigPolicyAction( + raw = VALID_HOST_WITH_PATH, + currentlyStored = VALID_HOST_WITH_PATH + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw valid host with path prefix replaces stored bare host`() { + val result = operationsDomainConfigPolicyAction( + raw = VALID_HOST_WITH_PATH, + currentlyStored = VALID_HOST + ) + + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST_WITH_PATH), result) + } + + // endregion + private companion object { const val VALID_HOST = "anonymizer.client.ru" const val VALID_HOST_WITH_SCHEME = "https://anonymizer.client.ru" const val VALID_HOST_WITH_TRAILING_SLASH = "https://anonymizer.client.ru/" const val ANOTHER_VALID_HOST = "new-anonymizer.client.ru" + const val VALID_HOST_WITH_PATH = "https://api-v2.letu.ru/api/mindbox-regular" } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt index b92fc3d2..c87da918 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt @@ -199,6 +199,37 @@ class GatewayManagerTest { assertTrue(url.startsWith("https://anonymizer.client.ru/")) } + @Test + fun `operationsDomain with path prefix appends operation endpoint after the prefix`() { + val config = mockConfiguration.copy(operationsDomain = "https://api-v2.letu.ru/api/mindbox-regular") + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue( + "Expected endpoint appended after path prefix, got: $url", + url.startsWith("https://api-v2.letu.ru/api/mindbox-regular/v3/operations/sync") + ) + } + + @Test + fun `operationsDomainFromConfig with path prefix appends operation endpoint after the prefix`() { + every { MindboxPreferences.operationsDomainFromConfig } returns "api-v2.letu.ru/api/mindbox-regular" + val config = mockConfiguration.copy(operationsDomain = null) + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue( + "Expected endpoint appended after path prefix, got: $url", + url.startsWith("https://api-v2.letu.ru/api/mindbox-regular/v3/operations/sync") + ) + } + + @Test + fun `operationsDomain path prefix with trailing slash does not produce double slash`() { + val config = mockConfiguration.copy(operationsDomain = "domain.com/api/v2/") + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.startsWith("https://domain.com/api/v2/v3/operations/sync")) + } + @Test fun `operationsDomain does not affect endpoint ID in URL`() { val config = mockConfiguration.copy(