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