Add instrumentation tests for storage plugin (SAF)
This commit is contained in:
parent
2f62e9515c
commit
22aaaeb1fd
8 changed files with 272 additions and 4 deletions
|
@ -2,7 +2,7 @@ package com.stevesoltys.seedvault
|
||||||
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.runner.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
|
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
|
||||||
|
|
252
app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
Normal file
252
app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderBackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.deleteContents
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.KoinComponent
|
||||||
|
import org.koin.core.inject
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PluginTest : KoinComponent {
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val metadataManager: MetadataManager by inject()
|
||||||
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val mockedSettingsManager: SettingsManager = mockk()
|
||||||
|
private val storage = DocumentsStorage(context, metadataManager, mockedSettingsManager)
|
||||||
|
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager)
|
||||||
|
private val restorePlugin: RestorePlugin = DocumentsProviderRestorePlugin(context, storage)
|
||||||
|
|
||||||
|
private val token = Random.nextLong()
|
||||||
|
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
|
||||||
|
private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
|
||||||
|
storage.rootBackupDir?.deleteContents()
|
||||||
|
?: error("Select a storage location in the app first!")
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
storage.rootBackupDir?.deleteContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testProviderPackageName() {
|
||||||
|
assertNotNull(backupPlugin.providerPackageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMetadataWriteRead() {
|
||||||
|
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false
|
||||||
|
assertTrue(backupPlugin.initializeDevice(newToken = token))
|
||||||
|
|
||||||
|
// write metadata
|
||||||
|
val metadata = getRandomByteArray()
|
||||||
|
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
|
||||||
|
|
||||||
|
// get available backups, expect only one with our token and no error
|
||||||
|
var availableBackups = restorePlugin.getAvailableBackups()?.toList()
|
||||||
|
check(availableBackups != null)
|
||||||
|
assertEquals(1, availableBackups.size)
|
||||||
|
assertEquals(token, availableBackups[0].token)
|
||||||
|
assertFalse(availableBackups[0].error)
|
||||||
|
|
||||||
|
// read metadata matches what was written earlier
|
||||||
|
assertEquals(metadata, availableBackups[0].inputStream)
|
||||||
|
|
||||||
|
// initializing again (without changing storage) keeps restore set with same token
|
||||||
|
assertFalse(backupPlugin.initializeDevice(newToken = token + 1))
|
||||||
|
availableBackups = restorePlugin.getAvailableBackups()?.toList()
|
||||||
|
check(availableBackups != null)
|
||||||
|
assertEquals(1, availableBackups.size)
|
||||||
|
assertEquals(token, availableBackups[0].token)
|
||||||
|
assertFalse(availableBackups[0].error)
|
||||||
|
|
||||||
|
// metadata hasn't changed
|
||||||
|
assertEquals(metadata, availableBackups[0].inputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testApkWriteRead() {
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// write random bytes as APK
|
||||||
|
val apk = getRandomByteArray(1337)
|
||||||
|
backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk)
|
||||||
|
|
||||||
|
// assert that read APK bytes match what was written
|
||||||
|
assertEquals(apk, restorePlugin.getApkInputStream(token, packageInfo.packageName))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testKvBackupRestore() {
|
||||||
|
// define shortcuts
|
||||||
|
val kvBackup = backupPlugin.kvBackupPlugin
|
||||||
|
val kvRestore = restorePlugin.kvRestorePlugin
|
||||||
|
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// no data available for given package
|
||||||
|
assertFalse(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
assertFalse(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
|
||||||
|
// define key/value pair records
|
||||||
|
val record1 = Pair(getRandomBase64(23), getRandomByteArray(1337))
|
||||||
|
val record2 = Pair(getRandomBase64(42), getRandomByteArray(42 * 1024))
|
||||||
|
val record3 = Pair(getRandomBase64(255), getRandomByteArray(5 * 1024 * 1024))
|
||||||
|
|
||||||
|
// write first record
|
||||||
|
kvBackup.ensureRecordStorageForPackage(packageInfo)
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, record1.first).writeAndClose(record1.second)
|
||||||
|
|
||||||
|
// data is now available for current token and given package, but not for different token
|
||||||
|
assertTrue(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
assertFalse(kvRestore.hasDataForPackage(token + 1, packageInfo))
|
||||||
|
|
||||||
|
// record for package is found and returned properly
|
||||||
|
var records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(1, records.size)
|
||||||
|
assertEquals(record1.first, records[0])
|
||||||
|
assertEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first))
|
||||||
|
|
||||||
|
// write second and third record
|
||||||
|
kvBackup.ensureRecordStorageForPackage(packageInfo)
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, record2.first).writeAndClose(record2.second)
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, record3.first).writeAndClose(record3.second)
|
||||||
|
|
||||||
|
// all records for package are found and returned properly
|
||||||
|
records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(listOf(record1.first, record2.first, record3.first).sorted(), records.sorted())
|
||||||
|
assertEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first))
|
||||||
|
assertEquals(record2.second, kvRestore.getInputStreamForRecord(token, packageInfo, record2.first))
|
||||||
|
assertEquals(record3.second, kvRestore.getInputStreamForRecord(token, packageInfo, record3.first))
|
||||||
|
|
||||||
|
// delete record3 and ensure that the other two are still found
|
||||||
|
kvBackup.deleteRecord(packageInfo, record3.first)
|
||||||
|
records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(listOf(record1.first, record2.first).sorted(), records.sorted())
|
||||||
|
|
||||||
|
// remove all data of package and ensure that it is gone
|
||||||
|
kvBackup.removeDataOfPackage(packageInfo)
|
||||||
|
assertFalse(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
assertFalse(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMaxKvKeyLength() {
|
||||||
|
// define shortcuts
|
||||||
|
val kvBackup = backupPlugin.kvBackupPlugin
|
||||||
|
val kvRestore = restorePlugin.kvRestorePlugin
|
||||||
|
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// define record with maximum key length and one above the maximum
|
||||||
|
val recordMax = Pair(getRandomBase64(255), getRandomByteArray(1024))
|
||||||
|
val recordOver = Pair(getRandomBase64(256), getRandomByteArray(1024))
|
||||||
|
|
||||||
|
// write max record
|
||||||
|
kvBackup.ensureRecordStorageForPackage(packageInfo)
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, recordMax.first).writeAndClose(recordMax.second)
|
||||||
|
|
||||||
|
// max record is found correctly
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
var records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(listOf(recordMax.first), records)
|
||||||
|
|
||||||
|
// write exceeding key length record
|
||||||
|
kvBackup.ensureRecordStorageForPackage(packageInfo)
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first).writeAndClose(recordOver.second)
|
||||||
|
|
||||||
|
// exceeding record gets truncated
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertNotEquals(listOf(recordMax.first, recordOver.first).sorted(), records.sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFullBackupRestore() {
|
||||||
|
// define shortcuts
|
||||||
|
val fullBackup = backupPlugin.fullBackupPlugin
|
||||||
|
val fullRestore = restorePlugin.fullRestorePlugin
|
||||||
|
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// no data available initially
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
|
||||||
|
// write full backup data
|
||||||
|
val data = getRandomByteArray(5 * 1024 * 1024)
|
||||||
|
fullBackup.getOutputStream(packageInfo).writeAndClose(data)
|
||||||
|
|
||||||
|
// data is available now, but only this token
|
||||||
|
assertTrue(fullRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token + 1, packageInfo))
|
||||||
|
|
||||||
|
// restore data matches backed up data
|
||||||
|
assertEquals(data, fullRestore.getInputStreamForPackage(token, packageInfo))
|
||||||
|
|
||||||
|
// write and check data for second package
|
||||||
|
val data2 = getRandomByteArray(5 * 1024 * 1024)
|
||||||
|
fullBackup.getOutputStream(packageInfo2).writeAndClose(data2)
|
||||||
|
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
assertEquals(data2, fullRestore.getInputStreamForPackage(token, packageInfo2))
|
||||||
|
|
||||||
|
// remove data of first package again and ensure that no more data is found
|
||||||
|
fullBackup.removeDataOfPackage(packageInfo)
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
|
||||||
|
// second package is still there
|
||||||
|
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
|
||||||
|
// ensure that it gets deleted as well
|
||||||
|
fullBackup.removeDataOfPackage(packageInfo2)
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initStorage(token: Long) {
|
||||||
|
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true
|
||||||
|
assertTrue(backupPlugin.initializeDevice(newToken = token))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun OutputStream.writeAndClose(data: ByteArray) = use {
|
||||||
|
it.write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEquals(data: ByteArray, inputStream: InputStream?) = inputStream?.use {
|
||||||
|
assertArrayEquals(data, it.readBytes())
|
||||||
|
} ?: error("no input stream")
|
||||||
|
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ internal class DocumentsProviderFullRestorePlugin(
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
||||||
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
|
val backupDir = documentsStorage.getFullBackupDir(token) ?: return false
|
||||||
return backupDir.findFile(packageInfo.packageName) != null
|
return backupDir.findFile(packageInfo.packageName) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,7 @@ internal class DocumentsProviderRestorePlugin(
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
private fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
||||||
val backupSets = ArrayList<BackupSet>()
|
val backupSets = ArrayList<BackupSet>()
|
||||||
val files = try {
|
val files = try {
|
||||||
// block until the DocumentsProvider has results
|
// block until the DocumentsProvider has results
|
||||||
|
|
|
@ -204,6 +204,7 @@ fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
|
||||||
})
|
})
|
||||||
val timeout = MINUTES.toMillis(2)
|
val timeout = MINUTES.toMillis(2)
|
||||||
var time = 0
|
var time = 0
|
||||||
|
// TODO replace loop with callback flow or something similar
|
||||||
while (!loaded && time < timeout) {
|
while (!loaded && time < timeout) {
|
||||||
Thread.sleep(50)
|
Thread.sleep(50)
|
||||||
time += 50
|
time += 50
|
||||||
|
|
|
@ -49,6 +49,7 @@ class SettingsManager(context: Context) {
|
||||||
return Storage(uri, name, isUsb)
|
return Storage(uri, name, isUsb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO find a better solution for this hack abusing the settings manager
|
||||||
fun getAndResetIsStorageChanging(): Boolean {
|
fun getAndResetIsStorageChanging(): Boolean {
|
||||||
return isStorageChanging.getAndSet(false)
|
return isStorageChanging.getAndSet(false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,8 @@ interface KVRestorePlugin {
|
||||||
/**
|
/**
|
||||||
* Return all record keys for the given token and package.
|
* Return all record keys for the given token and package.
|
||||||
*
|
*
|
||||||
|
* Note: Implementations might expect that you call [hasDataForPackage] before.
|
||||||
|
*
|
||||||
* For file-based plugins, this is usually a list of file names in the package directory.
|
* For file-based plugins, this is usually a list of file names in the package directory.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
@ -23,6 +25,8 @@ interface KVRestorePlugin {
|
||||||
/**
|
/**
|
||||||
* Return an [InputStream] for the given token, package and key
|
* Return an [InputStream] for the given token, package and key
|
||||||
* which will provide the record's encrypted value.
|
* which will provide the record's encrypted value.
|
||||||
|
*
|
||||||
|
* Note: Implementations might expect that you call [hasDataForPackage] before.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
|
fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
|
||||||
|
|
|
@ -10,7 +10,7 @@ fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply
|
||||||
Random.nextBytes(this)
|
Random.nextBytes(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
|
private val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
|
||||||
|
|
||||||
fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
|
fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
|
||||||
return (1..size)
|
return (1..size)
|
||||||
|
@ -19,6 +19,16 @@ fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
|
||||||
.joinToString("")
|
.joinToString("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL-save version (RFC 4648)
|
||||||
|
private val base64CharPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '+' + '_' + '='
|
||||||
|
|
||||||
|
fun getRandomBase64(size: Int = Random.nextInt(1, 255)): String {
|
||||||
|
return (1..size)
|
||||||
|
.map { Random.nextInt(0, base64CharPool.size) }
|
||||||
|
.map(base64CharPool::get)
|
||||||
|
.joinToString("")
|
||||||
|
}
|
||||||
|
|
||||||
fun ByteArray.toHexString(): String {
|
fun ByteArray.toHexString(): String {
|
||||||
var str = ""
|
var str = ""
|
||||||
for (b in this) {
|
for (b in this) {
|
||||||
|
|
Loading…
Reference in a new issue