feat: add provider login flow

This commit is contained in:
2026-08-23 21:58:04 +08:00
parent 6f7d3248b0
commit cc8eeebdc4
43 changed files with 2277 additions and 91 deletions
@@ -0,0 +1,28 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.concurrent.ConcurrentHashMap
class FakeCredentialStore : CredentialStore {
private val values = ConcurrentHashMap<ProviderProfileId, LoginCredentials>()
override suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit> {
values[profileId] = credentials
return DomainResult.Success(Unit)
}
override suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?> = DomainResult.Success(values[profileId])
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> {
values.remove(profileId)
return DomainResult.Success(Unit)
}
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeLocalSessionGateway : LocalSessionGateway {
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = DomainResult.Success(
Session(profileId, credentials.username).also { flow(profileId).value = it },
)
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = DomainResult.Success(
if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID,
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
flow(profileId).value = null
return DomainResult.Success(Unit)
}
private fun flow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
sessions.getOrPut(profileId) { MutableStateFlow(null) }
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeProviderProfileStore : ProviderProfileStore {
private val profileValues = ConcurrentHashMap<ProviderProfileId, ProviderProfile>()
private val preferenceValues = ConcurrentHashMap<ProviderProfileId, LoginPreferences>()
private val profilesFlow = MutableStateFlow<List<ProviderProfile>>(emptyList())
private val activeProfileFlow = MutableStateFlow<ProviderProfile?>(null)
private val preferenceFlows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<LoginPreferences>>()
override fun observeProfiles(): Flow<List<ProviderProfile>> = profilesFlow
override fun observeActiveProfile(): Flow<ProviderProfile?> = activeProfileFlow
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
preferenceFlow(profileId)
override suspend fun profiles(): List<ProviderProfile> = profilesFlow.value
override suspend fun activeProfile(): ProviderProfile? = activeProfileFlow.value
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferenceFlow(profileId).value
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> {
profileValues[profile.id] = profile
profilesFlow.value = profileValues.values.sortedBy { it.id.value }
if (activeProfileFlow.value?.id == profile.id) activeProfileFlow.value = profile
return DomainResult.Success(Unit)
}
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> {
activeProfileFlow.value = profileId?.let(profileValues::get)
return DomainResult.Success(Unit)
}
override suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit> {
preferenceValues[preferences.profileId] = preferences
preferenceFlow(preferences.profileId).value = preferences
return DomainResult.Success(Unit)
}
private fun preferenceFlow(profileId: ProviderProfileId): MutableStateFlow<LoginPreferences> =
preferenceFlows.getOrPut(profileId) {
MutableStateFlow(
preferenceValues[profileId] ?: LoginPreferences(profileId),
)
}
}
@@ -0,0 +1,29 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FakeCredentialStoreTest {
@Test
fun credentialsAreIsolatedByProfileAndClearIndependently() = runTest {
val store = FakeCredentialStore()
val first = ProviderProfileId("first")
val second = ProviderProfileId("second")
val firstCredentials = LoginCredentials(Username("one"), Password("secret-one"))
val secondCredentials = LoginCredentials(Username("two"), Password("secret-two"))
store.saveCredentials(first, firstCredentials)
store.saveCredentials(second, secondCredentials)
store.clearCredentials(first)
assertNull((store.loadCredentials(first) as DomainResult.Success).value)
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
}
}
@@ -0,0 +1,30 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class FakeProviderProfileStoreTest {
@Test
fun storesProfilesActiveSelectionAndPreferences() = runTest {
val store = FakeProviderProfileStore()
val id = ProviderProfileId("local")
val profile = ProviderProfile(id, ProviderType.LOCAL_EXPERIENCE, "Local")
val preferences = LoginPreferences(id, Username("demo"), true, false)
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
store.setActiveProfile(id)
store.saveLoginPreferences(preferences)
assertEquals(listOf(profile), store.observeProfiles().first())
assertEquals(profile, store.observeActiveProfile().first())
assertEquals(preferences, store.observeLoginPreferences(id).first())
}
}
@@ -0,0 +1,129 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionCoordinatorTest {
private val remoteProfile = ProviderProfile(
ProviderProfileId("abox"),
ProviderType.ABOX,
"ABox",
)
private val localProfile = ProviderProfile(
ProviderProfileId("local"),
ProviderType.LOCAL_EXPERIENCE,
"Local",
)
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun localProviderCreatesSessionWithoutRemoteLogin() = runTest {
val fixture = Fixture()
val result = fixture.coordinator.selectProvider(localProfile)
assertTrue(result is DomainResult.Success)
assertEquals(localProfile.id, fixture.coordinator.observeActiveSession().first()?.profileId)
assertNull(fixture.remote.observeSession(localProfile.id).first())
}
@Test
fun loginPersistsIndependentUsernameAndAutoLoginChoices() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = false,
autoLogin = true,
)
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertNull(preferences.rememberedUsername)
assertTrue(preferences.autoLoginEnabled)
assertEquals(
credentials,
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
}
@Test
fun bootstrapUsesSavedAutoLoginCredentials() = runTest {
val fixture = Fixture()
fixture.profiles.saveProfile(remoteProfile)
fixture.profiles.setActiveProfile(remoteProfile.id)
fixture.profiles.saveLoginPreferences(
LoginPreferences(remoteProfile.id, autoLoginEnabled = true),
)
fixture.credentials.saveCredentials(remoteProfile.id, credentials)
val result = fixture.coordinator.bootstrap()
assertEquals(remoteProfile.id, (result as DomainResult.Success).value?.profileId)
}
@Test
fun remoteLogoutClearsPasswordButKeepsProfileAndUsername() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
fixture.coordinator.logout()
assertEquals(remoteProfile, fixture.profiles.activeProfile())
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertEquals(credentials.username, preferences.rememberedUsername)
assertTrue(!preferences.autoLoginEnabled)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
}
@Test
fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest {
val fixture = Fixture()
fixture.profiles.saveProfile(remoteProfile)
fixture.profiles.setActiveProfile(remoteProfile.id)
fixture.profiles.saveLoginPreferences(
LoginPreferences(remoteProfile.id, autoLoginEnabled = true),
)
fixture.credentials.saveCredentials(remoteProfile.id, credentials)
fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication())
val result = fixture.coordinator.recoverSession(remoteProfile.id)
assertTrue(result is DomainResult.Failure)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
}
private class Fixture {
val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore()
val remote = FakeSessionGateway()
private val local = FakeLocalSessionGateway()
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
}
}