Add unit tests for Checker

This commit is contained in:
Torsten Grote 2024-10-29 15:20:55 -03:00
parent beedafd042
commit 166f81b3a8
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
2 changed files with 318 additions and 13 deletions

View file

@ -50,24 +50,29 @@ internal class Checker(
@WorkerThread
suspend fun getBackupSize(): Long? {
// get all snapshots
val folder = TopLevelFolder(crypto.repoId)
val handles = mutableListOf<AppBackupFileType.Snapshot>()
try {
backendManager.backend.list(folder, AppBackupFileType.Snapshot::class) { fileInfo ->
handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
}
val snapshots = snapshotManager.onSnapshotsLoaded(handles)
this.snapshots = snapshots // remember loaded snapshots
this.handleSize = handles.size // remember number of snapshot handles we had
return try {
getBackupSizeInt()
} catch (e: Exception) {
log.error(e) { "Error loading snapshots: " }
// we swallow this exception, because an error will be shown in the next step
return null
null
}
}
private suspend fun getBackupSizeInt(): Long {
// get all snapshots
val folder = TopLevelFolder(crypto.repoId)
val handles = mutableListOf<AppBackupFileType.Snapshot>()
backendManager.backend.list(folder, AppBackupFileType.Snapshot::class) { fileInfo ->
handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
}
val snapshots = snapshotManager.onSnapshotsLoaded(handles)
this.snapshots = snapshots // remember loaded snapshots
this.handleSize = handles.size // remember number of snapshot handles we had
// get total disk space used by snapshots
val sizeMap = mutableMapOf<String, Int>()
snapshots?.forEach { snapshot ->
snapshots.forEach { snapshot ->
// add sizes to a map first, so we don't double count
snapshot.blobsMap.forEach { (chunkId, blob) -> sizeMap[chunkId] = blob.length }
}
@ -79,10 +84,11 @@ internal class Checker(
check(percent in 0..100) { "Percent $percent out of bounds." }
if (snapshots == null) try {
getBackupSize() // just get size again to be sure we get snapshots
getBackupSizeInt() // just get size again to be sure we get snapshots
} catch (e: Exception) {
nm.onCheckFinishedWithError(0, 0)
checkerResult = CheckerResult.GeneralError(e)
return
}
val snapshots = snapshots ?: error("Snapshots still null")
val handleSize = handleSize ?: error("Handle size still null")

View file

@ -0,0 +1,299 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.repo
import com.google.protobuf.ByteString
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.proto.SnapshotKt.blob
import com.stevesoltys.seedvault.proto.copy
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.AppBackupFileType
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.FileInfo
import org.calyxos.seedvault.core.backends.TopLevelFolder
import org.calyxos.seedvault.core.toHexString
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.MessageDigest
import kotlin.random.Random
internal class CheckerTest : TransportTest() {
private val backendManager: BackendManager = mockk()
private val snapshotManager: SnapshotManager = mockk()
private val loader: Loader = mockk()
private val nm: BackupNotificationManager = mockk()
private val backend: Backend = mockk()
private val checker = Checker(crypto, backendManager, snapshotManager, loader, nm)
private val folder = TopLevelFolder(repoId)
private val snapshotHandle1 =
AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
private val snapshotHandle2 =
AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
@Test
fun `getBackupSize returns 0 for no data`() = runBlocking {
expectLoadingSnapshots(emptyMap())
assertEquals(0, checker.getBackupSize())
}
@Test
fun `getBackupSize returns null on error`() = runBlocking {
every { crypto.repoId } returns repoId
every { backendManager.backend } returns backend
coEvery {
backend.list(folder, AppBackupFileType.Snapshot::class, callback = captureLambda())
} throws IOException()
assertNull(checker.getBackupSize())
}
@Test
fun `getBackupSize returns size without double counting blobs`() = runBlocking {
val snapshotMap = mapOf(
snapshotHandle1 to snapshot.copy { token = 1 },
snapshotHandle2 to snapshot.copy { token = 2 },
)
val expectedSize = blob1.length.toLong() + blob2.length.toLong()
expectLoadingSnapshots(snapshotMap)
assertEquals(expectedSize, checker.getBackupSize())
}
@Test
fun `check works even with no backup data`() = runBlocking {
expectLoadingSnapshots(emptyMap())
every { nm.onCheckFinishedWithError(0, 0) } just Runs
assertNull(checker.checkerResult)
checker.check(100)
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
val result = checker.checkerResult as CheckerResult.Error
assertEquals(emptyList<Snapshot>(), result.snapshots)
assertEquals(0, result.existingSnapshots)
}
@Test
fun `check returns error when loading snapshots fails`() = runBlocking {
every { crypto.repoId } returns repoId
every { backendManager.backend } returns backend
coEvery {
backend.list(folder, AppBackupFileType.Snapshot::class, callback = captureLambda())
} throws IOException("foo")
every { nm.onCheckFinishedWithError(0, 0) } just Runs
assertNull(checker.checkerResult)
checker.check(100)
// assert the right exception gets passed on in error result
assertInstanceOf(CheckerResult.GeneralError::class.java, checker.checkerResult)
val result = checker.checkerResult as CheckerResult.GeneralError
assertInstanceOf(IOException::class.java, result.e)
assertEquals("foo", result.e.message)
}
@Test
fun `check raises error for wrong chunkIDs`() = runBlocking {
val snapshotMap = mapOf(
snapshotHandle1 to snapshot.copy { token = 1 },
snapshotHandle2 to snapshot.copy { token = 2 },
)
expectLoadingSnapshots(snapshotMap)
val data = ByteArray(0)
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data)
coEvery { loader.loadFile(blobHandle2, null) } returns ByteArrayInputStream(data)
every { nm.onCheckFinishedWithError(any(), any()) } just Runs
assertNull(checker.checkerResult)
checker.check(100)
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
val result = checker.checkerResult as CheckerResult.Error
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
assertEquals(snapshotMap.values.toSet(), result.badSnapshots.toSet())
assertEquals(emptyList<Snapshot>(), result.goodSnapshots)
assertEquals(snapshotMap.size, result.existingSnapshots)
assertEquals(setOf(chunkId1, chunkId2), result.errorChunkIds)
}
@Test
fun `check raises error for loader failure`() = runBlocking {
// chunkId is "real"
val data1 = getRandomByteArray()
val chunkId1 = MessageDigest.getInstance("SHA-256").digest(data1).toHexString()
// each snapshot gets a different blob
val apk1 = apk.copy {
splits.clear()
splits.add(baseSplit.copy {
this.chunkIds.clear()
chunkIds.add(ByteString.fromHex(chunkId1))
})
}
val apk2 = apk.copy {
splits.clear()
splits.add(baseSplit.copy {
this.chunkIds.clear()
chunkIds.add(ByteString.fromHex(chunkId2))
})
}
val snapshotMap = mapOf(
snapshotHandle1 to snapshot.copy {
token = 1
apps[packageName] = app.copy { this.apk = apk1 }
blobs.clear()
blobs[chunkId1] = blob1
},
snapshotHandle2 to snapshot.copy {
token = 2
apps[packageName] = app.copy { this.apk = apk2 }
},
)
expectLoadingSnapshots(snapshotMap)
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data1)
coEvery { loader.loadFile(blobHandle2, null) } throws GeneralSecurityException()
every { nm.onCheckFinishedWithError(any(), any()) } just Runs
assertNull(checker.checkerResult)
checker.check(100)
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
val result = checker.checkerResult as CheckerResult.Error
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
assertEquals(listOf(snapshotMap[snapshotHandle1]), result.goodSnapshots)
assertEquals(listOf(snapshotMap[snapshotHandle2]), result.badSnapshots)
assertEquals(snapshotMap.size, result.existingSnapshots)
assertEquals(setOf(chunkId2), result.errorChunkIds)
}
@Test
fun `check with 100 percent works`() = runBlocking {
// get "real" data for blobs
val messageDigest = MessageDigest.getInstance("SHA-256")
val data1 = getRandomByteArray()
val data2 = getRandomByteArray()
val chunkId1 = messageDigest.digest(data1).toHexString()
val chunkId2 = messageDigest.digest(data2).toHexString()
val apk = apk.copy {
splits.clear()
splits.add(baseSplit.copy {
this.chunkIds.clear()
chunkIds.add(ByteString.fromHex(chunkId1))
})
splits.add(apkSplit.copy {
this.chunkIds.clear()
chunkIds.add(ByteString.fromHex(chunkId2))
})
}
val snapshot = snapshot.copy {
apps[packageName] = app.copy { this.apk = apk }
blobs[chunkId1] = blob1
blobs[chunkId2] = blob2
}
val snapshotMap = mapOf(
snapshotHandle1 to snapshot.copy { token = 1 },
snapshotHandle2 to snapshot.copy { token = 2 },
)
val expectedSize = blob1.length.toLong() + blob2.length.toLong()
expectLoadingSnapshots(snapshotMap)
coEvery { loader.loadFile(blobHandle1, null) } returns ByteArrayInputStream(data1)
coEvery { loader.loadFile(blobHandle2, null) } returns ByteArrayInputStream(data2)
every { nm.onCheckComplete(expectedSize, any()) } just Runs
assertNull(checker.checkerResult)
checker.check(100)
assertInstanceOf(CheckerResult.Success::class.java, checker.checkerResult)
val result = checker.checkerResult as CheckerResult.Success
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
assertEquals(100, result.percent)
assertEquals(expectedSize, result.size)
verify {
nm.onCheckComplete(any(), any())
}
}
@Test
fun `check prefers app data over APKs`() = runBlocking {
val appDataBlob = blob {
id = ByteString.copyFrom(Random.nextBytes(32))
length = Random.nextInt(0, Int.MAX_VALUE)
uncompressedLength = Random.nextInt(0, Int.MAX_VALUE)
}
val appDataBlobHandle1 = AppBackupFileType.Blob(repoId, appDataBlob.id.hexFromProto())
val appDataChunkId = Random.nextBytes(32).toHexString()
val snapshotMap = mapOf(
snapshotHandle1 to snapshot.copy {
token = 1
apps[packageName] = app.copy { chunkIds.add(ByteString.fromHex(appDataChunkId)) }
blobs[appDataChunkId] = appDataBlob
},
)
expectLoadingSnapshots(snapshotMap)
// only loading app data, not other blobs
coEvery { loader.loadFile(appDataBlobHandle1, null) } throws SecurityException()
every { nm.onCheckFinishedWithError(appDataBlob.length.toLong(), any()) } just Runs
assertNull(checker.checkerResult)
checker.check(1) // 1% to minimize chance of selecting a non-app random blob
assertInstanceOf(CheckerResult.Error::class.java, checker.checkerResult)
val result = checker.checkerResult as CheckerResult.Error
assertEquals(snapshotMap.values.toSet(), result.snapshots.toSet())
assertEquals(snapshotMap.values.toSet(), result.badSnapshots.toSet())
assertEquals(snapshotMap.size, result.existingSnapshots)
assertEquals(setOf(appDataChunkId), result.errorChunkIds)
coVerify(exactly = 0) {
loader.loadFile(blobHandle1, null)
loader.loadFile(blobHandle2, null)
}
}
private suspend fun expectLoadingSnapshots(
snapshots: Map<AppBackupFileType.Snapshot, Snapshot>,
) {
every { crypto.repoId } returns repoId
every { backendManager.backend } returns backend
coEvery {
backend.list(folder, AppBackupFileType.Snapshot::class, callback = captureLambda())
} answers {
snapshots.keys.forEach {
val fileInfo = FileInfo(it, Random.nextLong(Long.MAX_VALUE))
lambda<(FileInfo) -> Unit>().captured.invoke(fileInfo)
}
}
coEvery {
snapshotManager.onSnapshotsLoaded(snapshots.keys.toList())
} returns snapshots.values.toList()
}
}