diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt new file mode 100644 index 0000000..f85b5c0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt @@ -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(session.state.value) + + session.initialize() + + assertIs(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(ACCESS_TOKEN), meClient.accessTokens) + val authenticated = assertIs(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(REFRESHED_ACCESS_TOKEN), meClient.accessTokens) + val authenticated = assertIs(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(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(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(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(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(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() + val refreshCalls = mutableListOf() + val logoutCalls = mutableListOf() + + 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() + + 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", + ) + } +}