1
0
Fork 0

Merge pull request from grote/plugin-tests

Add instrumentation tests for storage plugin (SAF)
This commit is contained in:
Chirayu Desai 2020-08-12 18:04:45 +05:30 committed by GitHub
commit 91276268bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 325 additions and 8 deletions
app
build.gradle
src
androidTest/java/com/stevesoltys/seedvault
main/java/com/stevesoltys/seedvault
sharedTest/java/com/stevesoltys/seedvault
build.gradlegradle.properties
gradle/wrapper

View file

@ -129,14 +129,17 @@ dependencies {
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
def junit_version = "5.5.2"
def mockk_version = "1.10.0"
testImplementation aospDeps
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'org.robolectric:robolectric:4.3.1'
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
testImplementation 'io.mockk:mockk:1.9.3'
testImplementation "io.mockk:mockk:$mockk_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
}

View file

@ -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

View file

@ -0,0 +1,293 @@
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)
}
/**
* This test initializes the storage three times while creating two new restore sets.
*
* If this is run against a Nextcloud storage backend,
* it has a high chance of getting a loading cursor in the underlying queries
* that needs to get re-queried to get real results.
*/
@Test
fun testInitializationAndRestoreSets() {
// no backups available initially
assertEquals(0, restorePlugin.getAvailableBackups()?.toList()?.size)
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
assertFalse(restorePlugin.hasBackup(uri))
// define storage changing state for later
every {
mockedSettingsManager.getAndResetIsStorageChanging()
} returns true andThen true andThen false
// device needs initialization, because new and storage is changing
assertTrue(backupPlugin.initializeDevice(newToken = token))
// write metadata (needed for backup to be recognized)
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
// one backup available now
assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri))
// initializing again (while changing storage) does add a restore set
assertTrue(backupPlugin.initializeDevice(newToken = token + 1))
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri))
// initializing again (without changing storage) doesn't change number of restore sets
assertFalse(backupPlugin.initializeDevice(newToken = token + 2))
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
}
@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")
}

View file

@ -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
}

View file

@ -53,7 +53,7 @@ internal class DocumentsProviderRestorePlugin(
}
@WorkerThread
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
private fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
val files = try {
// block until the DocumentsProvider has results

View file

@ -204,6 +204,7 @@ fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
})
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

View file

@ -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)
}

View file

@ -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

View file

@ -10,7 +10,7 @@ fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply
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 {
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<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 {
var str = ""
for (b in this) {

View file

@ -2,6 +2,8 @@
buildscript {
// 1.3.21 Android 10
// 1.3.72 AOSP master (2020-08)
ext.kotlin_version = '1.3.61'
repositories {

3
gradle.properties Normal file
View file

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1g
android.useAndroidX=true
android.enableJetifier=false

View file

@ -1,7 +1,7 @@
#Thu Nov 08 02:00:38 GMT 2018
#Tue Aug 04 14:40:48 BRT 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
distributionSha256Sum=14cd15fc8cc8705bd69dcfa3c8fefb27eb7027f5de4b47a8b279218f76895a91
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip
distributionSha256Sum=143a28f54f1ae93ef4f72d862dbc3c438050d81bb45b4601eb7076e998362920