test(02-06): add failing auth session state tests
- cover restore, login, refresh failure, logout, and cancellation - assert Phase 2 authenticated householdId remains null
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AuthSessionTest {
|
||||
@Test
|
||||
fun emptyStoreInitializesLoadingToUnauthenticated() {
|
||||
runBlocking {
|
||||
val session = newSession(store = FakeAuthStateStore())
|
||||
|
||||
assertIs<AuthState.Loading>(session.state.value)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore()
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
loginResult =
|
||||
OidcResult.Success(
|
||||
authStateJson = AUTH_STATE_JSON,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
idToken = "id-token",
|
||||
expiresAtEpochMillis = 123_456L,
|
||||
),
|
||||
)
|
||||
val meClient = FakeMeClient(user = USER)
|
||||
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
|
||||
|
||||
val result = session.login()
|
||||
|
||||
assertEquals(AuthLoginResult.Success, result)
|
||||
assertEquals(AUTH_STATE_JSON, store.value)
|
||||
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
|
||||
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
|
||||
assertEquals(USER, authenticated.user)
|
||||
assertNull(authenticated.householdId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
refreshResult =
|
||||
OidcResult.Success(
|
||||
authStateJson = REFRESHED_AUTH_STATE_JSON,
|
||||
accessToken = REFRESHED_ACCESS_TOKEN,
|
||||
idToken = null,
|
||||
expiresAtEpochMillis = 789_000L,
|
||||
),
|
||||
)
|
||||
val meClient = FakeMeClient(user = USER)
|
||||
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertEquals(emptyList(), oidcClient.loginCalls)
|
||||
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
|
||||
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
|
||||
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
|
||||
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
|
||||
assertEquals(USER, authenticated.user)
|
||||
assertNull(authenticated.householdId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
refreshResult = OidcResult.AuthError("invalid_grant"),
|
||||
)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
FakeOidcClient(
|
||||
refreshResult = OidcResult.AuthError("token endpoint rejected refresh"),
|
||||
)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
|
||||
val oidcClient = FakeOidcClient()
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.logout()
|
||||
|
||||
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
|
||||
val oidcClient = FakeOidcClient(logoutThrows = true)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.logout()
|
||||
|
||||
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginCancelledMapsToUiRenderableCancelledResult() {
|
||||
runBlocking {
|
||||
val store = FakeAuthStateStore()
|
||||
val session =
|
||||
newSession(
|
||||
store = store,
|
||||
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
|
||||
)
|
||||
|
||||
val result = session.login()
|
||||
|
||||
assertEquals(AuthLoginResult.Cancelled, result)
|
||||
assertNull(store.value)
|
||||
assertIs<AuthState.Unauthenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun newSession(
|
||||
store: AuthStateStore = FakeAuthStateStore(),
|
||||
oidcClient: OidcClientGateway = FakeOidcClient(),
|
||||
meClient: MeGateway = FakeMeClient(user = USER),
|
||||
): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = oidcClient,
|
||||
store = store,
|
||||
meClient = meClient,
|
||||
)
|
||||
|
||||
private class FakeAuthStateStore(
|
||||
var value: String? = null,
|
||||
) : AuthStateStore {
|
||||
override fun read(): String? = value
|
||||
|
||||
override fun write(authStateJson: String) {
|
||||
value = authStateJson
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeOidcClient(
|
||||
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
|
||||
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
|
||||
private val logoutThrows: Boolean = false,
|
||||
) : OidcClientGateway {
|
||||
val loginCalls = mutableListOf<Unit>()
|
||||
val refreshCalls = mutableListOf<String>()
|
||||
val logoutCalls = mutableListOf<String>()
|
||||
|
||||
override suspend fun login(): OidcResult {
|
||||
loginCalls += Unit
|
||||
return loginResult
|
||||
}
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult {
|
||||
refreshCalls += authStateJson
|
||||
return refreshResult
|
||||
}
|
||||
|
||||
override suspend fun logout(authStateJson: String) {
|
||||
logoutCalls += authStateJson
|
||||
if (logoutThrows) {
|
||||
error("end-session failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeMeClient(
|
||||
private val user: User,
|
||||
) : MeGateway {
|
||||
val accessTokens = mutableListOf<String?>()
|
||||
|
||||
override suspend fun getMe(accessToken: String?): User {
|
||||
accessTokens += accessToken
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
|
||||
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
|
||||
const val ACCESS_TOKEN = "access-token"
|
||||
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
|
||||
|
||||
val USER =
|
||||
User(
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
sub = "authentik-sub",
|
||||
email = "user@example.invalid",
|
||||
displayName = "Recipe User",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user