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:
2026-04-28 16:50:32 +02:00
parent b364c3056e
commit 06e5eaf94e

View File

@@ -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",
)
}
}