fix: harden infrared key contracts

This commit is contained in:
2026-08-24 01:26:00 +08:00
parent 34c118394e
commit 435f0d37b4
4 changed files with 99 additions and 14 deletions
@@ -5,8 +5,13 @@ import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
fun interface InfraredKeyIdGenerator { fun nextId(): InfraredKeyId } fun interface InfraredKeyIdGenerator {
fun interface EpochMillisClock { fun now(): Long } fun nextId(): InfraredKeyId
}
fun interface EpochMillisClock {
fun now(): Long
}
class InfraredKeyFactory( class InfraredKeyFactory(
private val idGenerator: InfraredKeyIdGenerator, private val idGenerator: InfraredKeyIdGenerator,
@@ -18,16 +23,22 @@ class InfraredKeyFactory(
displayName: String, displayName: String,
): DomainResult<SavedInfraredKey> { ): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim() val normalizedName = displayName.trim()
if (profileId.value.isBlank() || deviceName.value.isBlank() || normalizedName.isBlank()) { if (
profileId.value.isBlank() ||
profileId.value != profileId.value.trim() ||
deviceName.value.isBlank() ||
deviceName.value != deviceName.value.trim() ||
normalizedName.isBlank()
) {
return DomainResult.Failure( return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"), DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
) )
} }
val id = idGenerator.nextId() val id = idGenerator.nextId()
val normalizedId = id.value.lowercase().filter { it in 'a'..'z' || it in '0'..'9' } val canonicalId = canonicalizeId(id.value)
if (normalizedId.isBlank()) { ?: return DomainResult.Failure(
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key id is invalid")) DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
} )
val now = clock.now() val now = clock.now()
return DomainResult.Success( return DomainResult.Success(
SavedInfraredKey( SavedInfraredKey(
@@ -35,7 +46,7 @@ class InfraredKeyFactory(
profileId = profileId, profileId = profileId,
deviceName = deviceName, deviceName = deviceName,
displayName = normalizedName, displayName = normalizedName,
sdkKey = "app_ir_${normalizedId.take(40)}", sdkKey = "app_ir_$canonicalId",
status = InfraredKeyStatus.LEARNING, status = InfraredKeyStatus.LEARNING,
lastErrorCode = null, lastErrorCode = null,
createdAtEpochMillis = now, createdAtEpochMillis = now,
@@ -43,4 +54,17 @@ class InfraredKeyFactory(
), ),
) )
} }
private fun canonicalizeId(value: String): String? = when {
ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase()
STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase()
else -> null
}
private companion object {
val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$")
val STANDARD_UUID = Regex(
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
)
}
} }
@@ -11,7 +11,11 @@ data class InfraredKeyRef(
val keyId: InfraredKeyId, val keyId: InfraredKeyId,
) )
enum class InfraredKeyStatus { LEARNING, READY, FAILED } enum class InfraredKeyStatus {
LEARNING,
READY,
FAILED,
}
data class SavedInfraredKey( data class SavedInfraredKey(
val id: InfraredKeyId, val id: InfraredKeyId,
@@ -6,15 +6,42 @@ import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface InfraredKeyRepository { interface InfraredKeyRepository {
fun observeKeys(profileId: ProviderProfileId, deviceName: DeviceName): Flow<DomainResult<List<SavedInfraredKey>>> fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>>
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
suspend fun update(key: SavedInfraredKey): DomainResult<Unit>
suspend fun delete(ref: InfraredKeyRef): DomainResult<Unit> suspend fun transitionStatus(
suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult<List<SavedInfraredKey>> ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
updatedAtEpochMillis: Long,
): DomainResult<Int>
} }
interface InfraredDeviceConfigurationRepository { interface InfraredDeviceConfigurationRepository {
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit>
} }
@@ -35,4 +35,34 @@ class InfraredKeyFactoryTest {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure) assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure)
} }
@Test
fun rejectsIdentifiersWithSurroundingWhitespace() {
assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure)
}
@Test
fun rejectsLossyOrOversizedIds() {
assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure)
assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure)
}
@Test
fun canonicalizesStandardUuidId() {
val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success
assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey)
}
private fun factoryWithId(value: String) = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) },
clock = EpochMillisClock { 1_724_435_200_000L },
)
private fun InfraredKeyFactory.createValidKey() = create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"Power",
)
} }