* fix(#688): route expired auth sessions to sign-in Adopt global 401 latch, WS disconnect, and Back/profile guards. * chore: clean up formatting noise * fix(ktlint): reorder imports to place java and kotlinx at end * fix(ktlint): place java.io.IOException at end of imports * test: fix NavBackStack type parameter in AuthenticationRoutingTest * test: add AuthSessionState tests to AuthLoginViewModelTest * fix(ktlint): fix import order in AuthLoginViewModelTest
This commit is contained in:
10 files changed
+217
-4
No files matched your search
@@ -0,0 +1,66 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.m57.hermescontrol.data.local.AuthSessionState
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class AuthenticationRoutingTest {
|
||||
@Before
|
||||
fun setup() {
|
||||
AuthSessionState.resetForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
AuthSessionState.resetForTest()
|
||||
NavigationController.backStack = null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExpiryPropagationState() {
|
||||
assertFalse(AuthSessionState.signInRequired.value)
|
||||
|
||||
AuthSessionState.requireSignIn()
|
||||
assertTrue(AuthSessionState.signInRequired.value)
|
||||
|
||||
AuthSessionState.markAuthenticated()
|
||||
assertFalse(AuthSessionState.signInRequired.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGoBackFallbackGuardsChatWhenSignInRequired() {
|
||||
val stack = NavBackStack<NavKey>(AuthLoginScreen)
|
||||
NavigationController.backStack = stack
|
||||
|
||||
AuthSessionState.requireSignIn()
|
||||
assertTrue(AuthSessionState.signInRequired.value)
|
||||
|
||||
// Attempting to go back from AuthLoginScreen when single element stack
|
||||
NavigationController.goBack(fallback = ChatScreen)
|
||||
|
||||
// Must fall back to LandingScreen, not ChatScreen
|
||||
assertEquals(LandingScreen, stack.lastOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGoBackClearsToLandingWhenSignInRequiredAndExposingProtectedScreen() {
|
||||
val stack = NavBackStack<NavKey>(ChatScreen)
|
||||
stack.add(AuthLoginScreen)
|
||||
NavigationController.backStack = stack
|
||||
|
||||
AuthSessionState.requireSignIn()
|
||||
assertTrue(AuthSessionState.signInRequired.value)
|
||||
|
||||
// Attempting to pop AuthLoginScreen when ChatScreen is underneath
|
||||
NavigationController.goBack()
|
||||
|
||||
// Must not expose ChatScreen
|
||||
assertEquals(LandingScreen, stack.lastOrNull())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthSessionState
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
@@ -196,4 +198,36 @@ class NetworkResultTest {
|
||||
assertTrue(unknownError.message.contains("Unexpected error"))
|
||||
assertEquals(1, callCount) // Should not retry for unknown exceptions
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSafeApiCall_authExpired_triggersLatchWhenReportTrue() =
|
||||
runBlocking {
|
||||
AuthSessionState.resetForTest()
|
||||
assertFalse(AuthSessionState.signInRequired.value)
|
||||
|
||||
val result =
|
||||
safeApiCall<String>(reportAuthExpiry = true) {
|
||||
Response.error(401, "Unauthorized".toResponseBody("text/plain".toMediaTypeOrNull()))
|
||||
}
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
assertTrue((result as NetworkResult.Failure).error is NetworkError.AuthExpired)
|
||||
assertTrue(AuthSessionState.signInRequired.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSafeApiCall_authExpired_doesNotTriggerLatchWhenReportFalse() =
|
||||
runBlocking {
|
||||
AuthSessionState.resetForTest()
|
||||
assertFalse(AuthSessionState.signInRequired.value)
|
||||
|
||||
val result =
|
||||
safeApiCall<String>(reportAuthExpiry = false) {
|
||||
Response.error(401, "Unauthorized".toResponseBody("text/plain".toMediaTypeOrNull()))
|
||||
}
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
assertTrue((result as NetworkResult.Failure).error is NetworkError.AuthExpired)
|
||||
assertFalse(AuthSessionState.signInRequired.value)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ package com.m57.hermescontrol.ui.authlogin
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.AuthSessionState
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
@@ -19,6 +22,7 @@ import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
@@ -152,4 +156,33 @@ class AuthLoginViewModelTest {
|
||||
viewModel.deriveAuthMode(authRequired = true, providers = listOf("weird")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadLoggedInProfiles when signInRequired is true clears profile shortcuts`() {
|
||||
AuthSessionState.requireSignIn()
|
||||
every { AuthManager.getConnectionProfiles() } returns
|
||||
listOf(
|
||||
ConnectionProfile(id = "p1", name = "P1", baseUrl = "http://127.0.0.1"),
|
||||
)
|
||||
every { AuthManager.getProfileToken("p1") } returns "token1"
|
||||
|
||||
viewModel.loadLoggedInProfiles()
|
||||
|
||||
assertTrue(viewModel.uiState.value.loggedInProfiles.isEmpty())
|
||||
AuthSessionState.resetForTest()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `useExistingProfile marks session authenticated`() {
|
||||
AuthSessionState.requireSignIn()
|
||||
mockkObject(HermesWsClient)
|
||||
every { AuthManager.setSelectedProfileId("p1") } returns Unit
|
||||
every { HermesWsClient.connect() } returns Unit
|
||||
|
||||
viewModel.useExistingProfile("p1")
|
||||
|
||||
assertFalse(AuthSessionState.signInRequired.value)
|
||||
assertTrue(viewModel.uiState.value.connectionSuccess)
|
||||
AuthSessionState.resetForTest()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user