From 22aaaeb1fdbe7326801e4e5d324ced5967726e7c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 6 Aug 2020 13:49:33 -0300 Subject: [PATCH] Add instrumentation tests for storage plugin (SAF) --- .../seedvault/DocumentsStorageTest.kt | 2 +- .../com/stevesoltys/seedvault/PluginTest.kt | 252 ++++++++++++++++++ .../saf/DocumentsProviderFullRestorePlugin.kt | 2 +- .../saf/DocumentsProviderRestorePlugin.kt | 2 +- .../seedvault/plugins/saf/DocumentsStorage.kt | 1 + .../seedvault/settings/SettingsManager.kt | 1 + .../transport/restore/KVRestorePlugin.kt | 4 + .../com/stevesoltys/seedvault/TestUtils.kt | 12 +- 8 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt index 3b673dbb..6a9cba82 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt @@ -2,7 +2,7 @@ package com.stevesoltys.seedvault import androidx.documentfile.provider.DocumentFile 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.plugins.saf.DocumentsStorage import com.stevesoltys.seedvault.plugins.saf.createOrGetFile diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt new file mode 100644 index 00000000..7f9954df --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt @@ -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") + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt index 3ed21176..57a0ae18 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt @@ -10,7 +10,7 @@ internal class DocumentsProviderFullRestorePlugin( @Throws(IOException::class) 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 } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt index d9375c2a..142046ab 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt @@ -53,7 +53,7 @@ internal class DocumentsProviderRestorePlugin( } @WorkerThread - fun getBackups(context: Context, rootDir: DocumentFile): List { + private fun getBackups(context: Context, rootDir: DocumentFile): List { val backupSets = ArrayList() val files = try { // block until the DocumentsProvider has results diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt index ff91fa9d..702aa429 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt @@ -204,6 +204,7 @@ fun DocumentFile.listFilesBlocking(context: Context): ArrayList { }) val timeout = MINUTES.toMillis(2) var time = 0 + // TODO replace loop with callback flow or something similar while (!loaded && time < timeout) { Thread.sleep(50) time += 50 diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 949bc2d9..913e3a21 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -49,6 +49,7 @@ class SettingsManager(context: Context) { return Storage(uri, name, isUsb) } + // TODO find a better solution for this hack abusing the settings manager fun getAndResetIsStorageChanging(): Boolean { return isStorageChanging.getAndSet(false) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt index 84b9d9e0..4769e3e5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt @@ -15,6 +15,8 @@ interface KVRestorePlugin { /** * 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. */ @Throws(IOException::class) @@ -23,6 +25,8 @@ interface KVRestorePlugin { /** * Return an [InputStream] for the given token, package and key * which will provide the record's encrypted value. + * + * Note: Implementations might expect that you call [hasDataForPackage] before. */ @Throws(IOException::class) fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream diff --git a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt index 517daf37..bc0e211b 100644 --- a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt +++ b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt @@ -10,7 +10,7 @@ fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply Random.nextBytes(this) } -private val charPool : List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.' +private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.' fun getRandomString(size: Int = Random.nextInt(1, 255)): String { return (1..size) @@ -19,6 +19,16 @@ fun getRandomString(size: Int = Random.nextInt(1, 255)): String { .joinToString("") } +// URL-save version (RFC 4648) +private val base64CharPool: List = ('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 { var str = "" for (b in this) {