Skip to content

Commit 6bed429

Browse files
authored
Merge pull request #108 from joelkanyi/normalize-auth-url
Auth URL validation and normalization
2 parents dedd811 + 8e13813 commit 6bed429

4 files changed

Lines changed: 153 additions & 6 deletions

File tree

auth/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ dependencies {
9898
implementation(libs.kotlinx.coroutines.android)
9999
implementation(libs.androidx.lifecycle.runtime.ktx)
100100
implementation(libs.androidx.lifecycle.viewmodel.compose)
101+
102+
// Unit tests
103+
testImplementation(libs.junit)
101104
}
102105

103106
kotlin {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.android.swingmusic.auth.presentation.util
2+
3+
object AuthUtils {
4+
fun normalizeUrl(url: String?): String? {
5+
val trimmed = url?.trim()
6+
if (trimmed.isNullOrEmpty()) return null
7+
// If it already has a scheme like http:// or https:// (or any scheme), leave it as-is
8+
val hasScheme = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://").containsMatchIn(trimmed)
9+
return if (hasScheme) trimmed else "https://$trimmed"
10+
}
11+
12+
/**
13+
* Validates URLs for login input.
14+
*
15+
* Rules:
16+
* - Allowed schemes: http, https, ftp
17+
* - Host: domain (with subdomains), localhost, or IPv4 address
18+
* - Optional port
19+
* - Optional path/query/fragment
20+
*/
21+
fun validInputUrl(url: String?): Boolean {
22+
val urlRegex = Regex(
23+
pattern = "^(https?|ftp)://(" +
24+
"localhost|" +
25+
"(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}|" +
26+
"(?:(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)){3})" +
27+
")(?::\\d{1,5})?(?:/\\S*)?$",
28+
options = setOf(RegexOption.IGNORE_CASE)
29+
)
30+
return url?.matches(urlRegex) == true
31+
}
32+
}

auth/src/main/java/com/android/swingmusic/auth/presentation/viewmodel/AuthViewModel.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import com.android.swingmusic.auth.presentation.event.AuthUiEvent.OnUsernameChan
1414
import com.android.swingmusic.auth.presentation.state.AuthState
1515
import com.android.swingmusic.auth.presentation.state.AuthUiState
1616
import com.android.swingmusic.auth.presentation.util.AuthError
17+
import com.android.swingmusic.auth.presentation.util.AuthUtils.normalizeUrl
18+
import com.android.swingmusic.auth.presentation.util.AuthUtils.validInputUrl
1719
import com.android.swingmusic.core.data.util.Resource
1820
import dagger.hilt.android.lifecycle.HiltViewModel
1921
import kotlinx.coroutines.channels.Channel
@@ -93,11 +95,13 @@ class AuthViewModel @Inject constructor(
9395
}
9496

9597
private fun logInWithUsernameAndPassword() {
96-
val baseUrl = _authUiState.value.baseUrl
98+
val inputBaseUrl = _authUiState.value.baseUrl
99+
val baseUrl = normalizeUrl(inputBaseUrl)
97100
val username = _authUiState.value.username
98101
val password = _authUiState.value.password
99102

100103
viewModelScope.launch {
104+
// Validate the normalized URL first; only persist back to UI if it's valid
101105
if (baseUrl.isNullOrEmpty() || !validInputUrl(baseUrl)) {
102106
_authUiState.value = _authUiState.value.copy(
103107
authState = AuthState.LOGGED_OUT,
@@ -107,6 +111,9 @@ class AuthViewModel @Inject constructor(
107111
return@launch
108112
}
109113

114+
// Persist the normalized, validated URL back to UI so the user sees the auto-prepended scheme
115+
_authUiState.value = _authUiState.value.copy(baseUrl = baseUrl)
116+
110117
if (username.isNullOrEmpty() || password.isNullOrEmpty()) {
111118
_authUiState.value = _authUiState.value.copy(
112119
authState = AuthState.LOGGED_OUT,
@@ -225,11 +232,6 @@ class AuthViewModel @Inject constructor(
225232
}
226233
}
227234

228-
private fun validInputUrl(url: String?): Boolean {
229-
val urlRegex = Regex("^(https?|ftp)://[^\\s/$.?#].\\S*$")
230-
return url?.matches(urlRegex) == true
231-
}
232-
233235
fun onAuthUiEvent(event: AuthUiEvent) {
234236
when (event) {
235237
is LogInWithQrCode -> {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.android.swingmusic.auth.presentation.util
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class AuthUtilsTest {
10+
11+
// normalizeUrl
12+
@Test
13+
fun `normalizeUrl returns null for null input`() {
14+
assertNull(AuthUtils.normalizeUrl(null))
15+
}
16+
17+
@Test
18+
fun `normalizeUrl returns null for blank input`() {
19+
assertNull(AuthUtils.normalizeUrl(" \t \n "))
20+
}
21+
22+
@Test
23+
fun `normalizeUrl trims whitespace without scheme`() {
24+
assertEquals("https://example.com", AuthUtils.normalizeUrl(" example.com "))
25+
}
26+
27+
@Test
28+
fun `normalizeUrl trims whitespace with scheme`() {
29+
assertEquals("https://example.com", AuthUtils.normalizeUrl(" https://example.com "))
30+
}
31+
32+
@Test
33+
fun `normalizeUrl prepends https when scheme is missing`() {
34+
assertEquals("https://example.com", AuthUtils.normalizeUrl("example.com"))
35+
assertEquals("https://sub.domain.com", AuthUtils.normalizeUrl("sub.domain.com"))
36+
}
37+
38+
@Test
39+
fun `normalizeUrl handles ports and IPs when missing scheme`() {
40+
assertEquals("https://example.com:8080", AuthUtils.normalizeUrl("example.com:8080"))
41+
assertEquals("https://192.168.1.1", AuthUtils.normalizeUrl("192.168.1.1"))
42+
assertEquals("https://192.168.1.1:8443", AuthUtils.normalizeUrl("192.168.1.1:8443"))
43+
assertEquals("https://localhost:3000", AuthUtils.normalizeUrl("localhost:3000"))
44+
}
45+
46+
@Test
47+
fun `normalizeUrl leaves http and https unchanged`() {
48+
assertEquals("http://example.com", AuthUtils.normalizeUrl("http://example.com"))
49+
assertEquals("https://example.com", AuthUtils.normalizeUrl("https://example.com"))
50+
}
51+
52+
@Test
53+
fun `normalizeUrl leaves other schemes unchanged`() {
54+
assertEquals("ftp://example.com", AuthUtils.normalizeUrl("ftp://example.com"))
55+
assertEquals("custom+scheme://host/path", AuthUtils.normalizeUrl("custom+scheme://host/path"))
56+
}
57+
58+
// validInputUrl
59+
@Test
60+
fun `validInputUrl accepts http, https, and ftp`() {
61+
assertTrue(AuthUtils.validInputUrl("http://example.com"))
62+
assertTrue(AuthUtils.validInputUrl("https://example.com"))
63+
assertTrue(AuthUtils.validInputUrl("ftp://example.com"))
64+
}
65+
66+
@Test
67+
fun `validInputUrl accepts domains with ports`() {
68+
assertTrue(AuthUtils.validInputUrl("https://example.com:8080"))
69+
assertTrue(AuthUtils.validInputUrl("http://sub.example.co.uk:3000/path"))
70+
}
71+
72+
@Test
73+
fun `validInputUrl accepts localhost and IPv4 with optional ports`() {
74+
assertTrue(AuthUtils.validInputUrl("http://localhost"))
75+
assertTrue(AuthUtils.validInputUrl("http://localhost:3000/health"))
76+
assertTrue(AuthUtils.validInputUrl("https://192.168.1.1"))
77+
assertTrue(AuthUtils.validInputUrl("https://192.168.1.1:8443/api"))
78+
}
79+
80+
@Test
81+
fun `validInputUrl rejects malformed IPv4`() {
82+
assertFalse(AuthUtils.validInputUrl("https://999.1.1.1"))
83+
assertFalse(AuthUtils.validInputUrl("http://256.256.256.256"))
84+
}
85+
86+
@Test
87+
fun `validInputUrl rejects missing scheme`() {
88+
assertFalse(AuthUtils.validInputUrl("example.com"))
89+
assertFalse(AuthUtils.validInputUrl("www.example.com"))
90+
}
91+
92+
@Test
93+
fun `validInputUrl rejects blank or null`() {
94+
assertFalse(AuthUtils.validInputUrl(null))
95+
assertFalse(AuthUtils.validInputUrl(" "))
96+
}
97+
98+
@Test
99+
fun `validInputUrl accepts typical paths and query`() {
100+
assertTrue(AuthUtils.validInputUrl("https://example.com/path?query=1#frag"))
101+
}
102+
103+
@Test
104+
fun `validInputUrl rejects obvious invalid urls and custom schemes`() {
105+
assertFalse(AuthUtils.validInputUrl("https:///example.com"))
106+
assertFalse(AuthUtils.validInputUrl("https://"))
107+
assertFalse(AuthUtils.validInputUrl("not a url"))
108+
assertFalse(AuthUtils.validInputUrl("custom+scheme://host/path"))
109+
}
110+
}

0 commit comments

Comments
 (0)