git.iko.soy/parseable-android
on master help sign in
Fix login flow: case-insensitive URL scheme, proper 401 handling, bypass checkAuth (#6)
63ee88diko
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+14
@@ -66,6 +66,20 @@ class ParseableRepository @Inject constructor(
66
66
67 suspend fun testConnection(): ApiResult<String> = apiClient.checkLiveness()
67 suspend fun testConnection(): ApiResult<String> = apiClient.checkLiveness()
68
68
69+ /**
70+ * Verify this is a Parseable server and credentials are valid.
71+ * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72+ */
73+ suspend fun verifyServer(): ApiResult<AboutInfo> {
74+ return when (val result = apiClient.getAbout()) {
75+ is ApiResult.Success -> {
76+ aboutCache = CacheEntry(result.data)
77+ result
78+ }
79+ is ApiResult.Error -> result
80+ }
81+ }
82+
69 suspend fun getAbout(): ApiResult<AboutInfo> {
83 suspend fun getAbout(): ApiResult<AboutInfo> {
70 aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
84 aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
71 return checkAuth(apiClient.getAbout()).also { result ->
85 return checkAuth(apiClient.getAbout()).also { result ->
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+12−8
@@ -120,7 +120,8 @@ class LoginViewModel @Inject constructor(
120 _state.update { it.copy(isLoading = true, error = null) }
120 _state.update { it.copy(isLoading = true, error = null) }
121
121
122 var url = current.serverUrl.trim()
122 var url = current.serverUrl.trim()
123+ val urlLower = url.lowercase()
123− if (!url.startsWith("http://") && !url.startsWith("https://")) {
124+ if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
124 url = if (current.allowInsecure) "http://$url" else "https://$url"
125 url = if (current.allowInsecure) "http://$url" else "https://$url"
125 }
126 }
126
127
@@ -144,25 +145,28 @@ class LoginViewModel @Inject constructor(
144 serverUrl = url,
145 serverUrl = url,
145 username = current.username.trim(),
146 username = current.username.trim(),
146 password = password,
147 password = password,
147− useTls = url.startsWith("https"),
148+ useTls = url.lowercase().startsWith("https"),
148 )
149 )
149
150
150 repository.configure(config)
151 repository.configure(config)
151
152
152 when (val result = repository.testConnection()) {
153 when (val result = repository.testConnection()) {
153 is ApiResult.Success -> {
154 is ApiResult.Success -> {
154− // Verify this is actually a Parseable server by fetching /about
155+ // Verify this is actually a Parseable server and credentials are valid.
156+ // Uses verifyServer() to avoid triggering the global auth-error handler.
155− when (val aboutResult = repository.getAbout()) {
157+ when (val aboutResult = repository.verifyServer()) {
156 is ApiResult.Success -> {
158 is ApiResult.Success -> {
157 settingsRepository.saveServerConfig(config)
159 settingsRepository.saveServerConfig(config)
158 _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
160 _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
159 }
161 }
160 is ApiResult.Error -> {
162 is ApiResult.Error -> {
163+ val errorMsg = if (aboutResult.isUnauthorized) {
164+ "Invalid credentials. Check your username and password."
165+ } else {
166+ "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}"
167+ }
161 _state.update {
168 _state.update {
162− it.copy(
163− isLoading = false,
169+ it.copy(isLoading = false, error = errorMsg)
164− error = "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}",
165− )
166 }
170 }
167 }
171 }
168 }
172 }
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+56−4
@@ -64,7 +64,7 @@ class LoginViewModelTest {
64 @Test
64 @Test
65 fun `onLogin succeeds with valid credentials`() = runTest {
65 fun `onLogin succeeds with valid credentials`() = runTest {
66 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
66 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
67− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.0"))
67+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
68
68
69 viewModel.onServerUrlChange("https://test.parseable.com")
69 viewModel.onServerUrlChange("https://test.parseable.com")
70 viewModel.onUsernameChange("admin")
70 viewModel.onUsernameChange("admin")
@@ -95,7 +95,7 @@ class LoginViewModelTest {
95 @Test
95 @Test
96 fun `onLogin fails when about check fails`() = runTest {
96 fun `onLogin fails when about check fails`() = runTest {
97 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
97 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
98− coEvery { repository.getAbout() } returns ApiResult.Error("not parseable", 404)
98+ coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
99
99
100 viewModel.onServerUrlChange("https://test.com")
100 viewModel.onServerUrlChange("https://test.com")
101 viewModel.onUsernameChange("admin")
101 viewModel.onUsernameChange("admin")
@@ -108,6 +108,23 @@ class LoginViewModelTest {
108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
109 }
109 }
110
110
111+ @Test
112+ fun `onLogin shows invalid credentials on 401`() = runTest {
113+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
114+ coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
115+
116+ viewModel.onServerUrlChange("https://test.parseable.com")
117+ viewModel.onUsernameChange("admin")
118+ viewModel.onPasswordChange("wrongpass")
119+ viewModel.onLogin()
120+
121+ val state = viewModel.state.value
122+ assertFalse(state.loginSuccess)
123+ assertNotNull(state.error)
124+ assertTrue(state.error!!.contains("Invalid credentials"))
125+ assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
126+ }
127+
111 @Test
128 @Test
112 fun `onServerUrlChange clears error`() {
129 fun `onServerUrlChange clears error`() {
113 viewModel.onLogin() // triggers error
130 viewModel.onLogin() // triggers error
@@ -147,7 +164,7 @@ class LoginViewModelTest {
147 @Test
164 @Test
148 fun `onLogin prepends https when not insecure`() = runTest {
165 fun `onLogin prepends https when not insecure`() = runTest {
149 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
166 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
150− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
167+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
151
168
152 viewModel.onServerUrlChange("test.parseable.com")
169 viewModel.onServerUrlChange("test.parseable.com")
153 viewModel.onUsernameChange("admin")
170 viewModel.onUsernameChange("admin")
@@ -162,7 +179,7 @@ class LoginViewModelTest {
162 @Test
179 @Test
163 fun `onLogin prepends http when insecure`() = runTest {
180 fun `onLogin prepends http when insecure`() = runTest {
164 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
181 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
165− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
182+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
166
183
167 viewModel.onServerUrlChange("test.parseable.com")
184 viewModel.onServerUrlChange("test.parseable.com")
168 viewModel.onAllowInsecureChange(true)
185 viewModel.onAllowInsecureChange(true)
@@ -174,4 +191,39 @@ class LoginViewModelTest {
174 repository.configure(match { it.serverUrl == "http://test.parseable.com" })
191 repository.configure(match { it.serverUrl == "http://test.parseable.com" })
175 }
192 }
176 }
193 }
194+
195+ @Test
196+ fun `onLogin handles uppercase URL scheme`() = runTest {
197+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
198+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
199+
200+ viewModel.onServerUrlChange("HTTPS://test.parseable.com")
201+ viewModel.onUsernameChange("admin")
202+ viewModel.onPasswordChange("pass")
203+ viewModel.onLogin()
204+
205+ coVerify {
206+ repository.configure(match {
207+ it.serverUrl == "HTTPS://test.parseable.com" && it.useTls
208+ })
209+ }
210+ }
211+
212+ @Test
213+ fun `onLogin handles mixed case URL scheme`() = runTest {
214+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
215+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
216+
217+ viewModel.onServerUrlChange("Http://test.parseable.com")
218+ viewModel.onAllowInsecureChange(true)
219+ viewModel.onUsernameChange("admin")
220+ viewModel.onPasswordChange("pass")
221+ viewModel.onLogin()
222+
223+ coVerify {
224+ repository.configure(match {
225+ it.serverUrl == "Http://test.parseable.com" && !it.useTls
226+ })
227+ }
228+ }
177}
229}