feat: add provider login flow (#4)
## Summary - replace the former three-provider model with two pre-login choices: - **ABox**: authenticate through the vendor ABSDK on the current local network - **Local experience**: establish a production simulation session without credentials, network access, or ABSDK calls - remove the compatible-backend product/server scope and safely ignore legacy persisted compatible-provider values - bind one process-wide `AbsdkBridge` for ABox session, socket, temperature/humidity, and infrared operations - add production `:runtime:simulation` implementations and route device operations by the active provider - retain provider-scoped username preferences, Keystore-encrypted automatic-login credentials, token recovery, logout, and session isolation - add regression coverage for authentication gating, profile/session alignment, provider switching cleanup, routing, and simulation behavior - align requirement, plan, architecture, and protocol documentation with LAN ABSDK access ## Verification ```text ./gradlew check testDebugUnitTest test BUILD SUCCESSFUL (220 actionable tasks) ./gradlew :android:app:assembleDebug :android:app:assembleRelease BUILD SUCCESSFUL (217 actionable tasks) ./gradlew :android:app:compileDebugAndroidTestKotlin BUILD SUCCESSFUL git diff --check passed ``` Emulator smoke validation: - provider selection displays ABox and Local experience only - Local experience enters the authenticated route without credentials - Local experience also enters while emulator Wi-Fi/data are disabled - Local Sign out and Switch provider return to provider selection - ABox opens the credential form; empty submission stays on login with the required-fields error ## Known constraints and accepted risks - The vendor JAR uses cleartext HTTP and hardcodes `a-box.com.cn`; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. `@CleartextAbsdkTransport`, `usesCleartextTraffic`, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity. - Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed. - The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated `feat/moler-infrared-control` branch. - `FakeDeviceCatalog` remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation. Reviewed-on: #4 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #4.
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
package com.flagship.abox.manager.session.credential
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AndroidKeystoreCredentialCipherTest {
|
||||
@Test
|
||||
fun encryptsWithRandomIvAndProfileBoundAad() {
|
||||
val cipher = AndroidKeystoreCredentialCipher(
|
||||
keyAlias = "abox_manager_test_${System.nanoTime()}",
|
||||
)
|
||||
val profile = ProviderProfileId("profile")
|
||||
val plaintext = "secret".toByteArray()
|
||||
|
||||
val first = cipher.encrypt(profile, plaintext)
|
||||
val second = cipher.encrypt(profile, plaintext)
|
||||
|
||||
assertFalse(first.contentEquals(second))
|
||||
assertArrayEquals(plaintext, cipher.decrypt(profile, first))
|
||||
assertArrayEquals(plaintext, cipher.decrypt(profile, second))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun rejectsTruncatedPayloadBeforeReadingHeader() {
|
||||
AndroidKeystoreCredentialCipher(
|
||||
keyAlias = "abox_manager_test_${System.nanoTime()}",
|
||||
).decrypt(ProviderProfileId("profile"), byteArrayOf(1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.flagship.abox.manager.session.credential
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class AndroidKeystoreCredentialCipher(
|
||||
private val keyAlias: String = KEY_ALIAS,
|
||||
) : CredentialCipher {
|
||||
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
|
||||
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
|
||||
val ciphertext = cipher.doFinal(plaintext)
|
||||
return ByteBuffer.allocate(2 + cipher.iv.size + ciphertext.size)
|
||||
.put(VERSION)
|
||||
.put(cipher.iv.size.toByte())
|
||||
.put(cipher.iv)
|
||||
.put(ciphertext)
|
||||
.array()
|
||||
}
|
||||
|
||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
||||
require(encrypted.size >= HEADER_SIZE) { "Credential payload is truncated" }
|
||||
val buffer = ByteBuffer.wrap(encrypted)
|
||||
require(buffer.get() == VERSION) { "Unsupported credential format" }
|
||||
val ivLength = buffer.get().toInt() and 0xFF
|
||||
require(ivLength in 12..16 && buffer.remaining() > ivLength) { "Invalid credential payload" }
|
||||
val iv = ByteArray(ivLength).also(buffer::get)
|
||||
val ciphertext = ByteArray(buffer.remaining()).also(buffer::get)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
|
||||
return cipher.doFinal(ciphertext)
|
||||
}
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||
if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias)
|
||||
}
|
||||
|
||||
private fun secretKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setKeySize(KEY_SIZE_BITS)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build(),
|
||||
)
|
||||
generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "abox_manager_credentials_v1"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val KEY_SIZE_BITS = 256
|
||||
private const val GCM_TAG_BITS = 128
|
||||
private const val HEADER_SIZE = 2
|
||||
private const val VERSION: Byte = 1
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.flagship.abox.manager.session.credential
|
||||
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
|
||||
interface CredentialCipher {
|
||||
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
|
||||
|
||||
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
|
||||
|
||||
fun resetPermanentlyInvalidatedKey()
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.flagship.abox.manager.session.credential
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.flagship.abox.manager.domain.CredentialStore
|
||||
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.Password
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import java.util.Base64
|
||||
import javax.crypto.AEADBadTagException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class KeystoreCredentialStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val cipher: CredentialCipher,
|
||||
) : CredentialStore {
|
||||
override suspend fun saveCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Unit> = operation {
|
||||
val encrypted = cipher.encrypt(profileId, encode(credentials))
|
||||
dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) }
|
||||
}
|
||||
|
||||
override suspend fun loadCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<LoginCredentials?> {
|
||||
return try {
|
||||
val value = dataStore.data.first()[credentialKey(profileId)]
|
||||
?: return DomainResult.Success(null)
|
||||
val encrypted = Base64.getDecoder().decode(value)
|
||||
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (error.isPermanentCredentialFailure()) {
|
||||
try {
|
||||
if (error.hasPermanentlyInvalidatedKey()) {
|
||||
cipher.resetPermanentlyInvalidatedKey()
|
||||
}
|
||||
dataStore.edit { it.remove(credentialKey(profileId)) }
|
||||
DomainResult.Success(null)
|
||||
} catch (cleanupError: CancellationException) {
|
||||
throw cleanupError
|
||||
} catch (cleanupError: Exception) {
|
||||
DomainResult.Failure(
|
||||
DomainError.Unknown(
|
||||
detail = cleanupError.message ?: cleanupError::class.java.simpleName,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
DomainResult.Failure(
|
||||
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> = operation {
|
||||
dataStore.edit { it.remove(credentialKey(profileId)) }
|
||||
}
|
||||
|
||||
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
|
||||
DataOutputStream(output).use { data ->
|
||||
data.writeByte(CREDENTIAL_FORMAT_VERSION)
|
||||
data.writeByteArray(credentials.username.value.toByteArray(Charsets.UTF_8))
|
||||
data.writeByteArray(credentials.password.value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
|
||||
private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
|
||||
ByteArrayInputStream(value),
|
||||
).use { data ->
|
||||
require(data.readUnsignedByte() == CREDENTIAL_FORMAT_VERSION) {
|
||||
"Unsupported credential data format"
|
||||
}
|
||||
val username = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8)
|
||||
val password = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8)
|
||||
require(data.available() == 0) { "Unexpected credential data" }
|
||||
LoginCredentials(Username(username), Password(password))
|
||||
}
|
||||
|
||||
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
|
||||
block()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(
|
||||
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CREDENTIAL_FORMAT_VERSION = 1
|
||||
private const val MAX_CREDENTIAL_FIELD_BYTES = 1_048_576
|
||||
|
||||
private fun credentialKey(profileId: ProviderProfileId) =
|
||||
stringPreferencesKey("profile_${profileId.value}_credential")
|
||||
}
|
||||
}
|
||||
|
||||
private fun DataOutputStream.writeByteArray(value: ByteArray) {
|
||||
require(value.size <= 1_048_576) { "Credential field is too large" }
|
||||
writeInt(value.size)
|
||||
write(value)
|
||||
}
|
||||
|
||||
private fun DataInputStream.readByteArray(maxSize: Int): ByteArray {
|
||||
val length = readInt()
|
||||
require(length in 0..maxSize && length <= available()) { "Invalid credential field length" }
|
||||
return ByteArray(length).also(::readFully)
|
||||
}
|
||||
|
||||
private fun Throwable.hasPermanentlyInvalidatedKey(): Boolean =
|
||||
this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true
|
||||
|
||||
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
|
||||
is AEADBadTagException,
|
||||
is KeyPermanentlyInvalidatedException,
|
||||
is IllegalArgumentException,
|
||||
-> true
|
||||
|
||||
else -> cause?.isPermanentCredentialFailure() == true
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.flagship.abox.manager.session.profile
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
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 com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
|
||||
class ProviderProfileStoreImpl(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : ProviderProfileStore {
|
||||
private val safeData: Flow<Preferences>
|
||||
get() = dataStore.data
|
||||
.retryWhen { cause, attempt ->
|
||||
val retry = cause is IOException && attempt < MAX_READ_RETRIES
|
||||
if (retry) delay(READ_RETRY_DELAY_MILLIS)
|
||||
retry
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||
|
||||
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
|
||||
|
||||
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
|
||||
safeData.map { preferencesFrom(it, profileId) }
|
||||
|
||||
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(safeData.first())
|
||||
|
||||
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(safeData.first())
|
||||
|
||||
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
|
||||
preferencesFrom(safeData.first(), profileId)
|
||||
|
||||
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PROFILE_IDS] = preferences[PROFILE_IDS].orEmpty() + profile.id.value
|
||||
preferences[profileTypeKey(profile.id)] = profile.type.name
|
||||
preferences[profileNameKey(profile.id)] = profile.displayName
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
|
||||
dataStore.edit { preferences ->
|
||||
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
|
||||
else preferences[ACTIVE_PROFILE_ID] = profileId.value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveLoginPreferences(
|
||||
preferences: LoginPreferences,
|
||||
): DomainResult<Unit> = operation {
|
||||
dataStore.edit { values ->
|
||||
val profileId = preferences.profileId
|
||||
values[rememberUsernameKey(profileId)] = preferences.rememberUsername
|
||||
values[autoLoginKey(profileId)] = preferences.autoLoginEnabled
|
||||
val username = preferences.rememberedUsername?.value
|
||||
if (username == null) values.remove(usernameKey(profileId))
|
||||
else values[usernameKey(profileId)] = username
|
||||
}
|
||||
}
|
||||
|
||||
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
|
||||
preferences[PROFILE_IDS].orEmpty().mapNotNull { idValue ->
|
||||
val id = ProviderProfileId(idValue)
|
||||
val type = preferences[profileTypeKey(id)]
|
||||
?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() }
|
||||
?: return@mapNotNull null
|
||||
val displayName = preferences[profileNameKey(id)] ?: return@mapNotNull null
|
||||
ProviderProfile(id, type, displayName)
|
||||
}.sortedBy { it.id.value }
|
||||
|
||||
private fun activeProfileFrom(preferences: Preferences): ProviderProfile? {
|
||||
val activeId = preferences[ACTIVE_PROFILE_ID] ?: return null
|
||||
return profilesFrom(preferences).firstOrNull { it.id.value == activeId }
|
||||
}
|
||||
|
||||
private fun preferencesFrom(
|
||||
preferences: Preferences,
|
||||
profileId: ProviderProfileId,
|
||||
): LoginPreferences = LoginPreferences(
|
||||
profileId = profileId,
|
||||
rememberedUsername = preferences[usernameKey(profileId)]?.let(::Username),
|
||||
rememberUsername = preferences[rememberUsernameKey(profileId)] ?: false,
|
||||
autoLoginEnabled = preferences[autoLoginKey(profileId)] ?: false,
|
||||
)
|
||||
|
||||
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
|
||||
block()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(
|
||||
com.flagship.abox.manager.domain.DomainError.Unknown(
|
||||
detail = error.message ?: error::class.java.simpleName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_READ_RETRIES = 2L
|
||||
private const val READ_RETRY_DELAY_MILLIS = 100L
|
||||
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
|
||||
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
|
||||
|
||||
private fun profileTypeKey(id: ProviderProfileId) =
|
||||
stringPreferencesKey("profile_${id.value}_type")
|
||||
|
||||
private fun profileNameKey(id: ProviderProfileId) =
|
||||
stringPreferencesKey("profile_${id.value}_name")
|
||||
|
||||
private fun usernameKey(id: ProviderProfileId) =
|
||||
stringPreferencesKey("profile_${id.value}_username")
|
||||
|
||||
private fun rememberUsernameKey(id: ProviderProfileId) =
|
||||
booleanPreferencesKey("profile_${id.value}_remember_username")
|
||||
|
||||
private fun autoLoginKey(id: ProviderProfileId) =
|
||||
booleanPreferencesKey("profile_${id.value}_auto_login")
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.flagship.abox.manager.session.profile
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
private const val SESSION_DATA_STORE_NAME = "provider_session"
|
||||
|
||||
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = SESSION_DATA_STORE_NAME,
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.flagship.abox.manager.session
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.session.credential.CredentialCipher
|
||||
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialCleanupFailureTest {
|
||||
@Test
|
||||
fun cleanupFailureReturnsDomainFailureInsteadOfThrowing() = runTest {
|
||||
val store = KeystoreCredentialStore(FailingCleanupDataStore(), UnusedCipher())
|
||||
|
||||
val result = store.loadCredentials(ProviderProfileId("profile"))
|
||||
|
||||
assertTrue(result is DomainResult.Failure)
|
||||
assertEquals("disk unavailable", (result as DomainResult.Failure).error.detail)
|
||||
}
|
||||
|
||||
private class FailingCleanupDataStore : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences> = flow {
|
||||
throw IllegalArgumentException("corrupt credential")
|
||||
}
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences = throw IOException("disk unavailable")
|
||||
}
|
||||
|
||||
private class UnusedCipher : CredentialCipher {
|
||||
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext
|
||||
|
||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.flagship.abox.manager.session
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
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 com.flagship.abox.manager.session.credential.CredentialCipher
|
||||
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialStoreTest {
|
||||
@Test
|
||||
fun storesClearsAndIsolatesCredentials() = runTest {
|
||||
val file = File.createTempFile("credentials", ".preferences_pb").apply { delete() }
|
||||
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val store = KeystoreCredentialStore(dataStore, ReversingCipher())
|
||||
val first = ProviderProfileId("alpha")
|
||||
val second = ProviderProfileId("bravo")
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun credentialsLargerThanWriteUtfLimitRoundTrip() = runTest {
|
||||
val file = File.createTempFile("large-credentials", ".preferences_pb").apply { delete() }
|
||||
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val store = KeystoreCredentialStore(dataStore, ReversingCipher())
|
||||
val profile = ProviderProfileId("large")
|
||||
val credentials = LoginCredentials(
|
||||
Username("u".repeat(70_000)),
|
||||
Password("p".repeat(70_000)),
|
||||
)
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), store.saveCredentials(profile, credentials))
|
||||
assertEquals(credentials, (store.loadCredentials(profile) as DomainResult.Success).value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedCredentialIsRemoved() = runTest {
|
||||
val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() }
|
||||
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val profile = ProviderProfileId("profile")
|
||||
val store = KeystoreCredentialStore(dataStore, RejectingCipher())
|
||||
store.saveCredentials(
|
||||
profile,
|
||||
LoginCredentials(Username("demo"), Password("secret")),
|
||||
)
|
||||
|
||||
assertNull((store.loadCredentials(profile) as DomainResult.Success).value)
|
||||
assertNull((store.loadCredentials(profile) as DomainResult.Success).value)
|
||||
}
|
||||
|
||||
private class RejectingCipher : CredentialCipher {
|
||||
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = byteArrayOf(1)
|
||||
|
||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
||||
throw IllegalArgumentException("Credential payload is truncated")
|
||||
}
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||
}
|
||||
|
||||
private class ReversingCipher : CredentialCipher {
|
||||
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray =
|
||||
(profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()
|
||||
|
||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
||||
val decoded = encrypted.reversedArray()
|
||||
val delimiter = decoded.indexOf(0)
|
||||
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
|
||||
return decoded.copyOfRange(delimiter + 1, decoded.size)
|
||||
}
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.flagship.abox.manager.session
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class ProviderProfileStoreFailureTest {
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest {
|
||||
val store = ProviderProfileStoreImpl(FailingReadDataStore())
|
||||
var completed = false
|
||||
|
||||
backgroundScope.launch {
|
||||
runCatching { withTimeout(250) { store.activeProfile() } }
|
||||
completed = true
|
||||
}
|
||||
advanceTimeBy(200)
|
||||
|
||||
assertFalse(completed)
|
||||
}
|
||||
|
||||
private class FailingReadDataStore : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences> = flow {
|
||||
throw IOException("disk unavailable")
|
||||
}
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences = transform(emptyPreferences())
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.flagship.abox.manager.session
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
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 com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ProviderProfileStoreTest {
|
||||
@Test
|
||||
fun persistsActiveProfileAndLoginPreferences() = runTest {
|
||||
val file = File.createTempFile("profiles", ".preferences_pb").apply { delete() }
|
||||
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val store = ProviderProfileStoreImpl(dataStore)
|
||||
val id = ProviderProfileId("abox")
|
||||
val profile = ProviderProfile(id, ProviderType.ABOX, "ABox")
|
||||
val preferences = LoginPreferences(id, Username("demo"), true, true)
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
|
||||
store.setActiveProfile(id)
|
||||
store.saveLoginPreferences(preferences)
|
||||
|
||||
assertEquals(profile, store.observeActiveProfile().first())
|
||||
assertEquals(preferences, store.observeLoginPreferences(id).first())
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.flagship.abox.manager.session
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RemovedProviderProfileTest {
|
||||
@Test
|
||||
fun removedCompatibleProfileIsIgnoredAndCannotRemainActive() = runTest {
|
||||
val file = File.createTempFile("removed-provider", ".preferences_pb").apply { delete() }
|
||||
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
dataStore.edit { values ->
|
||||
values[stringSetPreferencesKey("profile_ids")] = setOf("compatible-default")
|
||||
values[stringPreferencesKey("profile_compatible-default_type")] = "COMPATIBLE_BACKEND"
|
||||
values[stringPreferencesKey("profile_compatible-default_name")] = "Compatible backend"
|
||||
values[stringPreferencesKey("active_profile_id")] = "compatible-default"
|
||||
}
|
||||
val store = ProviderProfileStoreImpl(dataStore)
|
||||
|
||||
assertTrue(store.profiles().isEmpty())
|
||||
assertNull(store.activeProfile())
|
||||
assertNull(store.observeActiveProfile().first())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user