Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 19 additions & 1 deletion sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down