diff --git a/gradle/test-libs.versions.toml b/gradle/test-libs.versions.toml index d7faf9f..ed81b9e 100644 --- a/gradle/test-libs.versions.toml +++ b/gradle/test-libs.versions.toml @@ -15,4 +15,5 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-truth" } junit = { module = "junit:junit", version.ref = "junit" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } truth = { module = "com.google.truth:truth", version.ref = "truth" } diff --git a/lib/android/build.gradle.kts b/lib/android/build.gradle.kts index a8a422c..9d525c8 100644 --- a/lib/android/build.gradle.kts +++ b/lib/android/build.gradle.kts @@ -116,5 +116,5 @@ dependencies { androidTestImplementation(testLibs.androidx.test.truth) androidTestImplementation(testLibs.androidx.test.rules) androidTestImplementation(testLibs.androidx.test.runner) - androidTestImplementation(testLibs.mockk) + androidTestImplementation(testLibs.mockk.android) } diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/MoneroWalletSubject.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/MoneroWalletSubject.kt new file mode 100644 index 0000000..1c46a69 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/MoneroWalletSubject.kt @@ -0,0 +1,29 @@ +package im.molly.monero + +import com.google.common.truth.FailureMetadata +import com.google.common.truth.Subject +import com.google.common.truth.Truth.assertAbout +import kotlinx.coroutines.flow.first + +class MoneroWalletSubject private constructor( + metadata: FailureMetadata, + private val actual: MoneroWallet, +) : Subject(metadata, actual) { + + companion object { + fun assertThat(wallet: MoneroWallet): MoneroWalletSubject { + return assertAbout(factory).that(wallet) + } + + private val factory = Factory { metadata, actual: MoneroWallet -> + MoneroWalletSubject(metadata, actual) + } + } + + suspend fun matchesStateOf(expected: MoneroWallet) { + with(actual) { + check("publicAddress").that(publicAddress).isEqualTo(expected.publicAddress) + check("ledger").that(ledger().first()).isEqualTo(expected.ledger().first()) + } + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/SecretKeyParcelableTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/SecretKeyParcelableTest.kt index 8caab83..a68c3fb 100644 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/SecretKeyParcelableTest.kt +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/SecretKeyParcelableTest.kt @@ -8,7 +8,7 @@ import kotlin.random.Random class SecretKeyParcelableTest { @Test - fun testParcel() { + fun secretKeyIsParcelable() { val secret = Random.nextBytes(32) val originalKey = SecretKey(secret) diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/MoneroWalletTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/MoneroWalletTest.kt new file mode 100644 index 0000000..a0c951f --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/e2etest/MoneroWalletTest.kt @@ -0,0 +1,95 @@ +package im.molly.monero.e2etest + +import android.content.Context +import android.content.Intent +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ServiceTestRule +import com.google.common.truth.Truth.assertThat +import im.molly.monero.InMemoryWalletDataStore +import im.molly.monero.MoneroNetwork +import im.molly.monero.MoneroWallet +import im.molly.monero.MoneroWalletSubject +import im.molly.monero.WalletProvider +import im.molly.monero.internal.IWalletService +import im.molly.monero.internal.WalletServiceClient +import im.molly.monero.service.BaseWalletService +import im.molly.monero.service.InProcessWalletService +import im.molly.monero.service.SandboxedWalletService +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@LargeTest +//@RunWith(Parameterized::class) +abstract class MoneroWalletTest(private val serviceClass: Class) { + + @get:Rule + val walletServiceRule = ServiceTestRule() + + private lateinit var walletProvider: WalletProvider + + private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context } + + private fun bindService(): IWalletService { + val binder = walletServiceRule.bindService(Intent(context, serviceClass)) + return IWalletService.Stub.asInterface(binder) + } + + private fun unbindService() { + walletServiceRule.unbindService() + } + + @Before + fun setUp() { + val walletService = bindService() + walletProvider = WalletServiceClient.withBoundService(context, walletService) + } + + @After + fun tearDown() { + walletProvider.disconnect() + unbindService() + } + + @Test + fun testSaveToMultipleStores() = runTest { + val defaultStore = InMemoryWalletDataStore() + val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet, defaultStore) + wallet.save() + + val newStore = InMemoryWalletDataStore() + wallet.save(newStore) + + assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray()) + } + + @Test + fun testCreateAccountAndSave() = runTest { + val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet) + val newAccount = wallet.createAccount() + + withReopenedWallet(wallet) { original, reopened -> + MoneroWalletSubject.assertThat(reopened).matchesStateOf(original) + } + } + + private suspend fun withReopenedWallet( + wallet: MoneroWallet, + action: suspend (original: MoneroWallet, reopened: MoneroWallet) -> Unit, + ) { + walletProvider.openWallet( + network = wallet.network, + dataStore = InMemoryWalletDataStore().apply { + wallet.save(targetStore = this) + }, + ).use { reopened -> + action(wallet, reopened) + } + } +} + +class MoneroWalletInProcessTest : MoneroWalletTest(InProcessWalletService::class.java) +class MoneroWalletSandboxedTest : MoneroWalletTest(SandboxedWalletService::class.java) diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/internal/DataStoreAdapterTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/internal/DataStoreAdapterTest.kt new file mode 100644 index 0000000..67dd7d5 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/internal/DataStoreAdapterTest.kt @@ -0,0 +1,116 @@ +package im.molly.monero.internal + +import android.os.ParcelFileDescriptor +import com.google.common.truth.Truth.assertThat +import im.molly.monero.WalletDataStore +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException + +class DataStoreAdapterTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private val dataStore = mockk(relaxed = true) + private val testDispatcher: TestDispatcher = StandardTestDispatcher() + private val testIOException = IOException("Test IO Exception") + + private lateinit var adapter: DataStoreAdapter + + @Before + fun setUp() { + adapter = DataStoreAdapter(dataStore, ioDispatcher = testDispatcher) + } + + @Test + fun overwriteIsPassedToDataStore() = runTest(testDispatcher) { + coEvery { dataStore.save(any(), any()) } returns Unit + + listOf(true, false).forEach { + adapter.saveWithFd(overwrite = it) {} + coVerify(exactly = 1) { dataStore.save(any(), overwrite = it) } + } + } + + @Test + fun propagatesIOExceptionWhenLoadFails() = runTest(testDispatcher) { + coEvery { dataStore.load() } throws testIOException + + val exception = runCatching { + adapter.loadWithFd {} + }.exceptionOrNull() + + assertThat(exception).isEqualTo(testIOException) + } + + @Test + fun propagatesIOExceptionWhenSaveFails() = runTest(testDispatcher) { + coEvery { dataStore.save(any(), any()) } throws testIOException + + val exception = runCatching { + adapter.saveWithFd(overwrite = true) {} + }.exceptionOrNull() + + assertThat(exception).isEqualTo(testIOException) + } + + @Test + fun pipeIsAlwaysClosedAfterLoad() = runTest(testDispatcher) { + val (readFd, writeFd) = mockPipe() + + coEvery { dataStore.load() } returns ByteArrayInputStream(byteArrayOf(1, 2, 3)) + + adapter.loadWithFd({}) + coVerify { readFd.close() } + coVerify { writeFd.close() } + + clearAllMocks(answers = false) + + runCatching { + adapter.loadWithFd({ throw RuntimeException() }) + } + coVerify { readFd.close() } + coVerify { writeFd.close() } + } + + @Test + fun pipeIsAlwaysClosedAfterSave() = runTest(testDispatcher) { + val (readFd, writeFd) = mockPipe() + + coEvery { dataStore.save(any(), any()) } returns Unit + + adapter.saveWithFd(overwrite = true, {}) + coVerify { readFd.close() } + coVerify { writeFd.close() } + + clearAllMocks(answers = false) + + runCatching { + adapter.saveWithFd(overwrite = true, { throw RuntimeException() }) + } + coVerify { readFd.close() } + coVerify { writeFd.close() } + } + + private fun mockPipe(): Pair { + val readFd = mockk(relaxed = true) + val writeFd = mockk(relaxed = true) + + mockkStatic(ParcelFileDescriptor::class) + coEvery { ParcelFileDescriptor.createPipe() } returns arrayOf(readFd, writeFd) + + return readFd to writeFd + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/internal/NativeWalletTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/internal/NativeWalletTest.kt index a22ba26..48db3cb 100644 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/internal/NativeWalletTest.kt +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/internal/NativeWalletTest.kt @@ -55,7 +55,7 @@ class NativeWalletTest { } @Test - fun atGenesisBalanceIsZero() = runTest { + fun balanceIsZeroAtGenesis() = runTest { with( NativeWallet.localSyncWallet( networkId = MoneroNetwork.Mainnet.id, diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/mnemonics/MoneroMnemonicTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/mnemonics/MoneroMnemonicTest.kt index ad4b241..2c352db 100644 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/mnemonics/MoneroMnemonicTest.kt +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/mnemonics/MoneroMnemonicTest.kt @@ -20,7 +20,7 @@ class MoneroMnemonicTest { ) @Test - fun testKnownMnemonics() { + fun knownMnemonics() { testCases.forEach { validateMnemonicGeneration(it) validateEntropyRecovery(it) @@ -28,22 +28,22 @@ class MoneroMnemonicTest { } @Test(expected = IllegalArgumentException::class) - fun testEmptyEntropy() { + fun emptyEntropy() { MoneroMnemonic.generateMnemonic(ByteArray(0)) } @Test(expected = IllegalArgumentException::class) - fun testInvalidEntropy() { + fun invalidEntropy() { MoneroMnemonic.generateMnemonic(ByteArray(2)) } @Test(expected = IllegalArgumentException::class) - fun testEmptyWords() { + fun emptyWords() { MoneroMnemonic.recoverEntropy("") } @Test(expected = IllegalArgumentException::class) - fun testInvalidLanguage() { + fun invalidLanguage() { MoneroMnemonic.generateMnemonic(ByteArray(32), Locale("ZZ")) } diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt new file mode 100644 index 0000000..4159ae1 --- /dev/null +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/service/WalletServiceSandboxingTest.kt @@ -0,0 +1,26 @@ +package im.molly.monero.service + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class WalletServiceSandboxingTest { + + private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context } + + @Test + fun inProcessWalletServiceIsNotIsolated() = runTest { + InProcessWalletService.connect(context).use { walletProvider -> + assertThat(walletProvider.isServiceIsolated()).isFalse() + } + } + + @Test + fun sandboxedWalletServiceIsIsolated() = runTest { + SandboxedWalletService.connect(context).use { walletProvider -> + assertThat(walletProvider.isServiceIsolated()).isTrue() + } + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt index cf8fec2..b8977c9 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt @@ -23,6 +23,8 @@ interface WalletProvider : Closeable { client: MoneroNodeClient? = null, ): MoneroWallet + fun isServiceIsolated(): Boolean + fun disconnect() override fun close() { diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt index b72006c..9511d17 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/WalletServiceClient.kt @@ -146,6 +146,8 @@ internal class WalletServiceClient( } } + override fun isServiceIsolated(): Boolean = service.isRemote() + override fun disconnect() { context.unbindService(serviceConnection ?: return) }