Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.android.core.configuration.internal

import com.google.common.truth.Truth.assertThat
import org.hisp.dhis.android.core.BaseRealIntegrationTest
import org.hisp.dhis.android.core.D2Factory

class HttpHttpsProtocolSwitchRealIntegrationShould : BaseRealIntegrationTest() {

private val httpsUrl = "https://play.im.dhis2.org/stable-2-42-3-1/"
private val testUsername = "android"
private val testPassword = "Android123"

/**
* Verifies that HTTP and HTTPS URLs create DIFFERENT accounts/databases.
* This is the expected behavior since they are technically different endpoints.
*/
// @Test
fun login_with_https_then_http_should_create_different_accounts() {
val generator = DatabaseNameGenerator()

val httpsDbName = generator.getDatabaseName(httpsUrl, testUsername, false)
val httpDbName = generator.getDatabaseName(
"http://play.im.dhis2.org/stable-2-42-3-1/",
testUsername,
false,
)

// HTTP and HTTPS should generate DIFFERENT database names
assertThat(httpsDbName).isNotEqualTo(httpDbName)
assertThat(httpsDbName).startsWith("https-")
assertThat(httpDbName).startsWith("http-")
}

/**
* Verifies that different domain case generates the SAME database.
* Domain is case-insensitive per DNS standards.
*/
// @Test
fun login_with_different_domain_case_should_use_same_account() {
val lowercaseUrl = "https://play.im.dhis2.org/stable-2-42-3-1/"
val uppercaseUrl = "https://PLAY.IM.DHIS2.ORG/stable-2-42-3-1/"

d2.userModule().blockingLogIn(testUsername, testPassword, lowercaseUrl)

val accountsFirst = d2.userModule().accountManager().getAccounts()
assertThat(accountsFirst).hasSize(1)
val firstDbName = accountsFirst[0].databaseName()

d2.userModule().blockingLogOut()
D2Factory.clear()
d2 = D2Factory.forNewDatabase(isRealIntegration = true)
d2.userModule().blockingLogIn(testUsername, testPassword, uppercaseUrl)

val accountsSecond = d2.userModule().accountManager().getAccounts()

// Should be the same account (domain case is normalized)
assertThat(accountsSecond).hasSize(1)
assertThat(accountsSecond[0].databaseName()).isEqualTo(firstDbName)

// Cleanup
d2.userModule().accountManager().deleteCurrentAccount()
}

/**
* Verifies that different path case generates DIFFERENT databases.
* Path is case-sensitive per URL standards.
*/
// @Test
fun database_name_should_differ_for_different_path_case() {
val generator = DatabaseNameGenerator()

val lowercasePath = generator.getDatabaseName(
"https://play.dhis2.org/demo",
testUsername,
false,
)
val uppercasePath = generator.getDatabaseName(
"https://play.dhis2.org/DEMO",
testUsername,
false,
)

// Different path case should generate DIFFERENT database names
assertThat(lowercasePath).isNotEqualTo(uppercasePath)
}

/**
* Verifies the database name format includes protocol prefix.
*/
// @Test
fun database_name_should_include_protocol_prefix() {
val generator = DatabaseNameGenerator()

val httpsDbName = generator.getDatabaseName(httpsUrl, testUsername, false)

assertThat(httpsDbName).startsWith("https-")
assertThat(httpsDbName).contains("play-im-dhis2-org")
assertThat(httpsDbName).contains(testUsername)
assertThat(httpsDbName).endsWith(".db")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ internal class DatabaseNameGenerator {
/**
* Generates a unique database name based on serverUrl, username, and encryption.
*
* Format: <readable_part>_<username>_<hash>_<encrypted|unencrypted>.db
* Format: <protocol>-<domain>-<path>_<username>_<hash>_<encrypted|unencrypted>.db
*
* Example:
* - Input: "https://play.dhis2.org/android-current", "admin", true
* - Output: "play-dhis2-org-android-current_admin_a3f5b821_encrypted.db"
* - Output: "https-play-dhis2-org-android-current_admin_a3f5b821_encrypted.db"
*
* @return A unique, filesystem-safe database name
*/
fun getDatabaseName(serverUrl: String, username: String, encrypt: Boolean): String {
val encryptedStr = if (encrypt) "encrypted" else "unencrypted"
Expand Down Expand Up @@ -75,11 +77,10 @@ internal class DatabaseNameGenerator {
}

private fun processServerUrl(serverUrl: String): String {
return serverUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/")
.removeSuffix("/api")
val normalized = ServerUrlNormalizer.normalize(serverUrl)
return normalized
.replace("https://", "https-")
.replace("http://", "http-")
.replace("[^a-zA-Z0-9]".toRegex(), "-")
.replace("-+".toRegex(), "-")
.removePrefix("-")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,60 @@ package org.hisp.dhis.android.core.configuration.internal
/**
* Normalizes server URLs for consistent comparison and hash generation.
*
* This ensures that URLs like:
* - "https://server.com" and "http://server.com"
* - "HTTPS://SERVER.COM" and "https://server.com"
* - "https://server.com/" and "https://server.com"
* - "https://server.com/api" and "https://server.com"
* Examples:
* - "HTTPS://SERVER.COM/Path" -> "https://server.com/Path"
* - "https://server.com/path/" -> "https://server.com/path"
* - "https://server.com/path/api" -> "https://server.com/path"
*
* Are all treated as equivalent.
* Note: http://server.com and https://server.com are NOT equivalent.
*/
internal object ServerUrlNormalizer {

private const val HTTPS_PREFIX = "https://"
private const val HTTP_PREFIX = "http://"

/**
* Normalizes a server URL by:
* 1. Converting to lowercase
* 2. Removing protocol (http:// or https://)
* 1. Lowercasing the protocol and domain (path remains case-sensitive)
* 2. Converting backslashes to forward slashes
* 3. Removing trailing slash
* 4. Removing /api suffix
*/
fun normalize(url: String): String {
return url
.lowercase()
.removePrefix("https://")
.removePrefix("http://")
.replace('\\', '/')
val normalized = url.replace('\\', '/')
val (protocol, rest) = extractProtocol(normalized)
val normalizedRest = lowercaseDomain(rest)
.trimEnd('/')
.removeSuffix("/api")

return protocol + normalizedRest
}

/**
* Extracts and lowercases the protocol from the URL.
* Returns a pair of (protocol, restOfUrl).
*/
private fun extractProtocol(url: String): Pair<String, String> {
return when {
url.lowercase().startsWith(HTTPS_PREFIX) -> HTTPS_PREFIX to url.substring(HTTPS_PREFIX.length)
url.lowercase().startsWith(HTTP_PREFIX) -> HTTP_PREFIX to url.substring(HTTP_PREFIX.length)
else -> "" to url
}
}

/**
* Lowercases only the domain part of the URL, preserving the path case.
* Domain is everything before the first '/'.
*/
private fun lowercaseDomain(urlWithoutProtocol: String): String {
val slashIndex = urlWithoutProtocol.indexOf('/')
return if (slashIndex == -1) {
urlWithoutProtocol.lowercase()
} else {
val domain = urlWithoutProtocol.take(slashIndex).lowercase()
val path = urlWithoutProtocol.substring(slashIndex)
domain + path
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class DatabaseNameGeneratorShould {
fun return_name_with_server_username_hash_and_encryption_for_encrypted_db() {
val name = generator.getDatabaseName(url, username, true)

// Should have format: <readable>_<username>_<hash>_encrypted.db
assertThat(name).matches("play-dhis2-org-android-current_user23_[0-9a-f]{8}_encrypted\\.db")
// Should have format: https-<readable>_<username>_<hash>_encrypted.db
assertThat(name).matches("https-play-dhis2-org-android-current_user23_[0-9a-f]{8}_encrypted\\.db")
assertThat(name).contains("_user23_")
assertThat(name).endsWith("_encrypted.db")
}
Expand All @@ -54,16 +54,19 @@ class DatabaseNameGeneratorShould {
fun return_name_with_server_username_hash_and_encryption_for_unencrypted_db() {
val name = generator.getDatabaseName(url, username, false)

assertThat(name).matches("play-dhis2-org-android-current_user23_[0-9a-f]{8}_unencrypted\\.db")
assertThat(name).matches("https-play-dhis2-org-android-current_user23_[0-9a-f]{8}_unencrypted\\.db")
assertThat(name).endsWith("_unencrypted.db")
}

@Test
fun return_same_name_for_http_and_https() {
fun return_different_name_for_http_and_https() {
val nameHttps = generator.getDatabaseName(url, username, true)
val nameHttp = generator.getDatabaseName(urlHttp, username, true)

assertThat(nameHttps).isEqualTo(nameHttp)
// HTTP and HTTPS are different servers, should have different database names
assertThat(nameHttps).isNotEqualTo(nameHttp)
assertThat(nameHttps).startsWith("https-")
assertThat(nameHttp).startsWith("http-")
}

@Test
Expand Down Expand Up @@ -98,12 +101,9 @@ class DatabaseNameGeneratorShould {
val name1 = generator.getDatabaseName(url1, username, true)
val name2 = generator.getDatabaseName(url2, username, true)

// These URLs should generate DIFFERENT database names
// These URLs should generate DIFFERENT database names (hash distinguishes them)
assertThat(name1).isNotEqualTo(name2)

assertThat(name1).startsWith("play-dhis2-org-android-current_user23_")
assertThat(name2).startsWith("play-dhis2-org-android-current_user23_")

val hash1 = name1.substringAfter("user23_").substringBefore("_encrypted")
val hash2 = name2.substringAfter("user23_").substringBefore("_encrypted")
assertThat(hash1).isNotEqualTo(hash2)
Expand All @@ -126,14 +126,26 @@ class DatabaseNameGeneratorShould {
}

@Test
fun return_different_names_for_case_sensitive_urls() {
fun return_same_name_for_different_domain_case() {
val url1 = "https://PLAY.dhis2.org/android-current"
val url2 = "https://play.dhis2.org/android-current"

val name1 = generator.getDatabaseName(url1, username, true)
val name2 = generator.getDatabaseName(url2, username, true)

// Different URLs (even by case) should have different hashes
// Domain case is normalized, so these should be equal
assertThat(name1).isEqualTo(name2)
}

@Test
fun return_different_name_for_different_path_case() {
val url1 = "https://play.dhis2.org/Android-Current"
val url2 = "https://play.dhis2.org/android-current"

val name1 = generator.getDatabaseName(url1, username, true)
val name2 = generator.getDatabaseName(url2, username, true)

// Path case is preserved, so these should be different
assertThat(name1).isNotEqualTo(name2)
}

Expand All @@ -152,8 +164,8 @@ class DatabaseNameGeneratorShould {
@Suppress("DEPRECATION")
val oldName = generator.getOldDatabaseName(url, username, true)

// Old format: <readable>_<username>_encrypted.db (no hash)
assertThat(oldName).isEqualTo("play-dhis2-org-android-current_user23_encrypted.db")
// Old format includes protocol prefix now but no hash
assertThat(oldName).isEqualTo("https-play-dhis2-org-android-current_user23_encrypted.db")
assertThat(oldName).doesNotMatch(".*_[0-9a-f]{8}_.*")
}

Expand All @@ -163,6 +175,6 @@ class DatabaseNameGeneratorShould {
val nameWithBackslashes = generator.getDatabaseName(urlWithBackslashes, username, true)

assertThat(nameWithSlashes).isEqualTo(nameWithBackslashes)
assertThat(nameWithSlashes).matches("play-dhis2-org-android-current_user23_[0-9a-f]{8}_encrypted\\.db")
assertThat(nameWithSlashes).matches("https-play-dhis2-org-android-current_user23_[0-9a-f]{8}_encrypted\\.db")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ class DatabasesConfigurationHelperShould {
}

@Test
fun find_account_with_normalized_url_different_protocol() {
fun not_find_account_with_different_protocol() {
val account = DatabaseAccount.builder()
.username("admin")
.serverUrl("https://dhis2.org")
Expand All @@ -189,15 +189,14 @@ class DatabasesConfigurationHelperShould {
.accounts(listOf(account))
.build()

// Search with http instead of https
// Search with http instead of https - should NOT find (different servers)
val found = DatabaseConfigurationHelper.getAccount(
configuration,
"http://dhis2.org",
"admin",
)

assertThat(found).isNotNull()
assertThat(found).isEqualTo(account)
assertThat(found).isNull()
}

@Test
Expand Down
Loading