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 @@ -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)
}

Expand Down
7 changes: 4 additions & 3 deletions Mindbox/MBConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Mindbox/Network/Helpers/HostNormalizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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://"
Expand Down
4 changes: 3 additions & 1 deletion Mindbox/Network/Helpers/URLRequestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Mindbox/Validators/URLValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<slashIndex])
let path = String(value[slashIndex...])
return isValidHost(host)
&& path.range(of: pathPrefixPattern, options: .regularExpression) != nil
}

static func isValidHost(_ host: String) -> Bool {
guard !host.isEmpty, host.count <= maxHostLength else { return false }

Expand Down
83 changes: 83 additions & 0 deletions MindboxTests/Configuration/MBConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,89 @@ struct MBConfigurationTests {
}
}

@Test("operationsDomain accepts path prefix (MOBILE-258)")
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")
Expand Down
119 changes: 119 additions & 0 deletions MindboxTests/Network/OperationsURLRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,57 @@ struct OperationsURLRoutingTests {
#expect(url?.path == "/v3/operations/async")
}

// MARK: - Path prefix in operationsDomain (MOBILE-258)

@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
Expand Down Expand Up @@ -364,6 +415,74 @@ struct OperationsURLRoutingTests {
)
}

@Test("Policy — saves value with path prefix in canonical form (MOBILE-258)")
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)")
Expand Down