From 458b4879a5cf80e1217fe5a9baed9ca66c789489 Mon Sep 17 00:00:00 2001 From: Mpendulo Ndlovu Date: Mon, 19 Aug 2024 07:14:27 +0200 Subject: [PATCH] chore: ethereum unit tests --- .../java/io/metamask/androidsdk/Ethereum.kt | 31 +- .../io/metamask/androidsdk/InfuraProvider.kt | 4 +- .../io/metamask/androidsdk/SessionManager.kt | 2 +- .../io/metamask/androidsdk/EthereumTests.kt | 301 ++++++++++++++++++ .../MockCommunicationClientModule.kt | 75 ++--- .../metamask/androidsdk/MockInfuraProvider.kt | 13 + .../io/metamask/androidsdk/MockKeyStorage.kt | 4 + 7 files changed, 362 insertions(+), 68 deletions(-) create mode 100644 metamask-android-sdk/src/test/java/io/metamask/androidsdk/EthereumTests.kt create mode 100644 metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockInfuraProvider.kt diff --git a/metamask-android-sdk/src/main/java/io/metamask/androidsdk/Ethereum.kt b/metamask-android-sdk/src/main/java/io/metamask/androidsdk/Ethereum.kt index 0f6c626e..a16bae75 100644 --- a/metamask-android-sdk/src/main/java/io/metamask/androidsdk/Ethereum.kt +++ b/metamask-android-sdk/src/main/java/io/metamask/androidsdk/Ethereum.kt @@ -21,17 +21,7 @@ class Ethereum ( private val dappMetadata: DappMetadata, sdkOptions: SDKOptions? = null, private val logger: Logger = DefaultLogger, - private val communicationClientModule: CommunicationClientModule = CommunicationClientModule(context) - ): EthereumEventCallback { - private var connectRequestSent = false - - private val communicationClient: CommunicationClient? by lazy { - communicationClientModule.provideCommunicationClient(this) - } - - private val storage = communicationClientModule.provideKeyStorage() - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - + private val communicationClientModule: CommunicationClientModuleInterface = CommunicationClientModule(context), private val infuraProvider: InfuraProvider? = sdkOptions?.let { if (it.infuraAPIKey.isNotEmpty()) { InfuraProvider(it.infuraAPIKey) @@ -39,6 +29,14 @@ class Ethereum ( null } } + ): EthereumEventCallback { + private var connectRequestSent = false + + val communicationClient: CommunicationClient? by lazy { + communicationClientModule.provideCommunicationClient(this) + } + + private val storage = communicationClientModule.provideKeyStorage() // Ethereum LiveData private val _ethereumState = MutableLiveData(EthereumState("", "", "")) @@ -46,15 +44,12 @@ class Ethereum ( get() = checkNotNull(ethereumState.value) val ethereumState: LiveData get() = _ethereumState - // Expose plain variables for developers who prefer not using observing live data via ethereumState - val chainId: String - get() = if (currentEthereumState.chainId.isEmpty()) { currentEthereumState.chainId } else { cachedChainId } - val selectedAddress: String - get() = if (currentEthereumState.selectedAddress.isEmpty()) { currentEthereumState.selectedAddress } else { cachedAccount } - private var cachedChainId = "" private var cachedAccount = "" + var selectedAddress: String = ethereumState.value?.selectedAddress.takeIf { !it.isNullOrEmpty() } ?: cachedAccount + var chainId: String = ethereumState.value?.selectedAddress.takeIf { !it.isNullOrEmpty() } ?: cachedChainId + // Toggle SDK tracking var enableDebug: Boolean = true set(value) { @@ -106,6 +101,7 @@ class Ethereum ( ) ) if (account.isNotEmpty()) { + selectedAddress = account storage.putValue(account, key = SessionManager.SESSION_ACCOUNT_KEY, SessionManager.SESSION_CONFIG_FILE) } } @@ -119,6 +115,7 @@ class Ethereum ( ) ) if (newChainId.isNotEmpty()) { + chainId = newChainId storage.putValue(newChainId, key = SessionManager.SESSION_CHAIN_ID_KEY, SessionManager.SESSION_CONFIG_FILE) } } diff --git a/metamask-android-sdk/src/main/java/io/metamask/androidsdk/InfuraProvider.kt b/metamask-android-sdk/src/main/java/io/metamask/androidsdk/InfuraProvider.kt index 5aa4e03b..03956de1 100644 --- a/metamask-android-sdk/src/main/java/io/metamask/androidsdk/InfuraProvider.kt +++ b/metamask-android-sdk/src/main/java/io/metamask/androidsdk/InfuraProvider.kt @@ -2,7 +2,7 @@ package io.metamask.androidsdk import org.json.JSONObject -class InfuraProvider(private val infuraAPIKey: String, private val logger: Logger = DefaultLogger) { +open class InfuraProvider(private val infuraAPIKey: String, private val logger: Logger = DefaultLogger) { val rpcUrls: Map = mapOf( // ###### Ethereum ###### // Mainnet @@ -70,7 +70,7 @@ class InfuraProvider(private val infuraAPIKey: String, private val logger: Logge return !rpcUrls[chainId].isNullOrEmpty() } - fun makeRequest(request: RpcRequest, chainId: String, dappMetadata: DappMetadata, callback: ((Result) -> Unit)?) { + open fun makeRequest(request: RpcRequest, chainId: String, dappMetadata: DappMetadata, callback: ((Result) -> Unit)?) { val httpClient = HttpClient() val devicePlatformInfo = DeviceInfo.platformDescription diff --git a/metamask-android-sdk/src/main/java/io/metamask/androidsdk/SessionManager.kt b/metamask-android-sdk/src/main/java/io/metamask/androidsdk/SessionManager.kt index b6196520..95619113 100644 --- a/metamask-android-sdk/src/main/java/io/metamask/androidsdk/SessionManager.kt +++ b/metamask-android-sdk/src/main/java/io/metamask/androidsdk/SessionManager.kt @@ -7,7 +7,7 @@ import java.lang.reflect.Type class SessionManager( private val store: SecureStorage, - private var sessionDuration: Long = 30 * 24 * 3600, // 30 days default + var sessionDuration: Long = 30 * 24 * 3600, // 30 days default private val logger: Logger = DefaultLogger ) { var sessionId: String = "" diff --git a/metamask-android-sdk/src/test/java/io/metamask/androidsdk/EthereumTests.kt b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/EthereumTests.kt new file mode 100644 index 00000000..09c5be1b --- /dev/null +++ b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/EthereumTests.kt @@ -0,0 +1,301 @@ + +package io.metamask.androidsdk + +import android.content.ComponentName +import android.content.Context +import android.os.IBinder +import io.metamask.androidsdk.KeyExchangeMessageType.* +import io.metamask.nativesdk.IMessegeService +import io.metamask.androidsdk.Event.* +import io.metamask.androidsdk.MockInfuraProvider +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.mock +import org.mockito.Mockito + +import org.robolectric.RobolectricTestRunner +import org.junit.runner.RunWith +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any + +@RunWith(RobolectricTestRunner::class) +class EthereumTests { + + private lateinit var context: Context + + private lateinit var mockEthereumEventCallback: MockEthereumEventCallback + private lateinit var logger: Logger + private lateinit var keyExchange: KeyExchange + private lateinit var sessionManager: SessionManager + private lateinit var mockClientServiceConnection: MockClientServiceConnection + private lateinit var mockClientMessageServiceCallback: MockClientMessageServiceCallback + private lateinit var mockCrypto: MockCrypto + private lateinit var mockTracker: MockTracker + + private lateinit var mockCommunicationClientModule: MockCommunicationClientModule + private lateinit var ethereum: Ethereum + private lateinit var mockStorage: MockKeyStorage + private lateinit var communicationClient: CommunicationClient + private lateinit var mockInfuraProvider: MockInfuraProvider + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + context = mock() + + logger = TestLogger + mockEthereumEventCallback = MockEthereumEventCallback() + mockClientServiceConnection = MockClientServiceConnection() + mockClientMessageServiceCallback = MockClientMessageServiceCallback() + + mockCrypto = MockCrypto() + mockTracker = MockTracker() + keyExchange = KeyExchange(mockCrypto, logger) + mockStorage = MockKeyStorage() + sessionManager = SessionManager(mockStorage) + mockInfuraProvider = MockInfuraProvider(SDKOptions(infuraAPIKey = "01234567").infuraAPIKey, logger) + + mockCommunicationClientModule = MockCommunicationClientModule( + context, + mockStorage, + sessionManager, + keyExchange, + mockClientServiceConnection, + mockClientMessageServiceCallback, + mockTracker, + logger + ) + ethereum = Ethereum( + context, + DappMetadata("testApp","http://www.testapp.com", iconUrl = null, base64Icon = null), + sdkOptions = SDKOptions(infuraAPIKey = "01234567"), + logger, + mockCommunicationClientModule, + mockInfuraProvider + ) + communicationClient = ethereum.communicationClient!! + } + + @Test + fun testUpdateAccount() = runBlocking { + val testAccount = "0x12345" + ethereum.updateAccount(testAccount) + delay(10) + assertEquals(testAccount, ethereum.selectedAddress) + assertEquals(testAccount, mockStorage.getValue(SessionManager.SESSION_ACCOUNT_KEY, SessionManager.SESSION_CONFIG_FILE)) + } + + @Test + fun testUpdateChainId() = runBlocking { + val testChainId = "0x1" + ethereum.updateChainId(testChainId) + delay(10) + assertEquals(testChainId, ethereum.chainId) + assertEquals(testChainId, mockStorage.getValue(SessionManager.SESSION_CHAIN_ID_KEY, SessionManager.SESSION_CONFIG_FILE)) + } + + @Test + fun testEthereumConnect() { + val testResult: Result = Result.Success.Item("0x123456") + var callbackResult: Result? = null + + prepareCommunicationClient() + + ethereum.connect { result -> + callbackResult = result + } + + val requestId = findRequestIdForAccountRequest(EthereumMethod.ETH_REQUEST_ACCOUNTS) + communicationClient.completeRequest(requestId, testResult) + + assertTrue(callbackResult is Result.Success) + assertEquals(callbackResult, testResult) + + val trackedEvent = mockTracker.trackedEvent + assertEquals(trackedEvent, SDK_CONNECTION_AUTHORIZED) + assertNotNull(mockTracker.trackedEventParams) + assertEquals(SDK_CONNECTION_AUTHORIZED.value, mockTracker.trackedEventParams?.get("event")) + } + + @Test + fun testEthereumConnectError() { + val errorCode = 4001 + val errorMessage = "User rejected request" + val testResult: Result = Result.Error(RequestError(errorCode, errorMessage)) + var callbackResult: Result? = null + + prepareCommunicationClient() + + // Assuming the connect method modifies the internal state and captures results + ethereum.connect { result -> + callbackResult = result + } + + // Simulate the completion of the request made by connect + val requestId = findRequestIdForAccountRequest(EthereumMethod.ETH_REQUEST_ACCOUNTS) + communicationClient.completeRequest(requestId, testResult) + + assertTrue(callbackResult is Result.Error) + assertEquals(testResult, callbackResult) + + val trackedEvent = mockTracker.trackedEvent + assertEquals(SDK_CONNECTION_REJECTED, trackedEvent) + assertNotNull(mockTracker.trackedEventParams) + assertEquals(SDK_CONNECTION_REJECTED.value, mockTracker.trackedEventParams?.get("event")) + } + + @Test + fun testConnectWith() { + val params: MutableMap = mutableMapOf( + "from" to "0x12345", + "to" to "0x98765", + "amount" to "0x1" + ) + + val transactionRequest = EthereumRequest( + method = EthereumMethod.ETH_SEND_TRANSACTION.value, + params = listOf(params) + ) + + var callbackResult: Result? = null + + prepareCommunicationClient() + + ethereum.connectWith(transactionRequest) { result -> + callbackResult = result + } + + val requestId = findRequestIdForAccountRequest(EthereumMethod.METAMASK_CONNECT_WITH) + val testResult: Result = Result.Success.Item("0x24680") + communicationClient.completeRequest(requestId, testResult) + + assertTrue(callbackResult is Result.Success) + assertEquals(callbackResult, testResult) + + val trackedEvent = mockTracker.trackedEvent + assertEquals(trackedEvent, SDK_CONNECTION_AUTHORIZED) + assertNotNull(mockTracker.trackedEventParams) + assertEquals(SDK_CONNECTION_AUTHORIZED.value, mockTracker.trackedEventParams?.get("event")) + } + + @Test + fun testConnectSign() { + val messageToSign = "Sign this message" + var callbackResult: Result? = null + + prepareCommunicationClient() + + ethereum.connectSign(messageToSign) { result -> + callbackResult = result + } + + val requestId = findRequestIdForAccountRequest(EthereumMethod.METAMASK_CONNECT_SIGN) + val testResult: Result = Result.Success.Item("0xdhjdheeeeeew") + communicationClient.completeRequest(requestId, testResult) + + // Assertions to verify the correct handling + assertTrue(callbackResult is Result.Success) + assertEquals(callbackResult, testResult) + + val trackedEvent = mockTracker.trackedEvent + assertEquals(Event.SDK_CONNECTION_AUTHORIZED, trackedEvent) + assertNotNull(mockTracker.trackedEventParams) + assertEquals(SDK_CONNECTION_AUTHORIZED.value, mockTracker.trackedEventParams?.get("event")) + } + + @Test + fun testUpdateSessionDuration() { + val newDuration = 10 * 24 * 3600L // 10 days + runBlocking { + ethereum.updateSessionDuration(newDuration) + delay(10) + + // Ensure session duration in session manager is updated + assertEquals(newDuration, sessionManager.sessionDuration) + } + } + + @Test + fun testClearSession() { + mockStorage.putValue("0x1", key = SessionManager.SESSION_CHAIN_ID_KEY, SessionManager.SESSION_CONFIG_FILE) + assertFalse(mockStorage.isClear()) + ethereum.clearSession() + assertTrue(mockStorage.isClear()) + } + + @Test + fun testMetaMaskOpenedForUserInteraction() { + val request = EthereumRequest(method = EthereumMethod.ETH_SEND_TRANSACTION.value, params = listOf("to: '0x456', value: '1000'")) + ethereum.connect {} + + ethereum.sendRequest(request) + + // Assuming `openMetaMask` does something observable like firing an intent + verify(context, atLeastOnce()).startActivity(any()) + } + + @Test + fun testReadOnlyRequestUsingInfura() { + val request = EthereumRequest(method = EthereumMethod.ETH_GET_BALANCE.value, params = listOf("0x123", "latest")) + val mockResponse = "{\"balance\": \"1000\"}" + mockInfuraProvider.mockResponse = mockResponse + + ethereum.connect {} + + ethereum.sendRequest(request) { result -> + assertTrue(result is Result.Success) + when (result) { + is Result.Success.Item -> { + assertEquals(mockResponse, result.value) + } + else -> { + fail("Result should be success") + } + } + } + } + + private fun findRequestIdForAccountRequest(method: EthereumMethod): String { + return communicationClient.submittedRequests.entries.find { + it.value.request.method == method.value + }?.key ?: throw IllegalStateException("No account request found") + } + + private fun prepareCommunicationClient() { + val mockBinder = Mockito.mock(IBinder::class.java) + val mockMessageService = Mockito.mock(IMessegeService::class.java) + `when`(IMessegeService.Stub.asInterface(mockBinder)).thenReturn(mockMessageService) + + // mock service connection + mockClientServiceConnection.onServiceConnected(ComponentName(context, "Service"), mockBinder) + + // mock receiver + val receiverKeyExchange = KeyExchange(MockCrypto(), logger) + + // exchange public keys + val receiverKeyExchangeMessage = KeyExchangeMessage(KEY_HANDSHAKE_ACK.name, receiverKeyExchange.publicKey) + val senderKeyExchangeMessage = KeyExchangeMessage(KEY_HANDSHAKE_ACK.name, keyExchange.publicKey) + + keyExchange.nextKeyExchangeMessage(receiverKeyExchangeMessage) + receiverKeyExchange.nextKeyExchangeMessage(senderKeyExchangeMessage) + + // mock key exchange complete + keyExchange.complete() + + // mock receiving ready message + val readyMessage = JSONObject().apply { + put(MessageType.TYPE.value, MessageType.READY.value) + }.toString() + val encryptedReadyMessage = receiverKeyExchange.encrypt(readyMessage) + + // simulate MetaMask Ready + communicationClient.handleMessage(encryptedReadyMessage) + } +} \ No newline at end of file diff --git a/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockCommunicationClientModule.kt b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockCommunicationClientModule.kt index 760a46b1..9a124376 100644 --- a/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockCommunicationClientModule.kt +++ b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockCommunicationClientModule.kt @@ -3,52 +3,31 @@ package io.metamask.androidsdk import android.content.Context import io.metamask.androidsdk.MockTracker -class MockCommunicationClientModule(private val context: Context): CommunicationClientModuleInterface{ - override fun provideKeyStorage(): SecureStorage { - return MockKeyStorage() - } - - override fun provideSessionManager(keyStorage: SecureStorage): SessionManager { - return SessionManager(keyStorage) - } - - override fun provideKeyExchange(): KeyExchange { - return KeyExchange(MockCrypto()) - } - - override fun provideLogger(): Logger { - return TestLogger - } - - override fun provideTracker(): Tracker { - return MockTracker() - } - - override fun provideClientServiceConnection(): ClientServiceConnection { - return MockClientServiceConnection() - } - - override fun provideClientMessageServiceCallback(): ClientMessageServiceCallback { - return MockClientMessageServiceCallback() - } - - override fun provideCommunicationClient(callback: EthereumEventCallback?): CommunicationClient { - val keyStorage = provideKeyStorage() - val sessionManager = provideSessionManager(keyStorage) - val keyExchange = provideKeyExchange() - val logger = provideLogger() - val tracker = provideTracker() - val serviceConnection = provideClientServiceConnection() - val clientMessageServiceCallback = provideClientMessageServiceCallback() - - return CommunicationClient( - context, - callback, - sessionManager, - keyExchange, - serviceConnection, - clientMessageServiceCallback, - tracker, - logger) - } +class MockCommunicationClientModule( + private val context: Context, + private val keyStorage: SecureStorage, + private val sessionManager: SessionManager, + private val keyExchange: KeyExchange, + private val serviceConnection: ClientServiceConnection, + private val clientMessageServiceCallback: ClientMessageServiceCallback, + private val tracker: Tracker, + private val logger: Logger): CommunicationClientModuleInterface { + + override fun provideKeyStorage(): SecureStorage = keyStorage + override fun provideSessionManager(keyStorage: SecureStorage): SessionManager = sessionManager + override fun provideKeyExchange(): KeyExchange = keyExchange + override fun provideLogger(): Logger = logger + override fun provideTracker(): Tracker = tracker + + override fun provideClientServiceConnection(): ClientServiceConnection = serviceConnection + override fun provideClientMessageServiceCallback(): ClientMessageServiceCallback = clientMessageServiceCallback + override fun provideCommunicationClient(callback: EthereumEventCallback?): CommunicationClient = CommunicationClient( + context, + callback, + sessionManager, + keyExchange, + serviceConnection, + clientMessageServiceCallback, + tracker, + logger) } \ No newline at end of file diff --git a/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockInfuraProvider.kt b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockInfuraProvider.kt new file mode 100644 index 00000000..13ef5dbb --- /dev/null +++ b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockInfuraProvider.kt @@ -0,0 +1,13 @@ +package io.metamask.androidsdk + +class MockInfuraProvider(private val infuraAPIKey: String, private val logger: Logger = TestLogger) : InfuraProvider(infuraAPIKey, logger) { + var mockResponse: String? = null // This can hold the mock response for the request. + + override fun makeRequest(request: RpcRequest, chainId: String, dappMetadata: DappMetadata, callback: ((Result) -> Unit)?) { + if (mockResponse != null) { + callback?.invoke(Result.Success.Item(mockResponse!!)) + } else { + callback?.invoke(Result.Error(RequestError(-1, "No response set in MockInfuraProvider"))) + } + } +} \ No newline at end of file diff --git a/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockKeyStorage.kt b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockKeyStorage.kt index 663b6fa2..64177be1 100644 --- a/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockKeyStorage.kt +++ b/metamask-android-sdk/src/test/java/io/metamask/androidsdk/MockKeyStorage.kt @@ -10,6 +10,10 @@ class MockKeyStorage : SecureStorage { private val keyMap: MutableMap = mutableMapOf() private val sharedPreferencesMap: MutableMap> = mutableMapOf() + fun isClear(): Boolean { + return sharedPreferencesMap[SessionManager.SESSION_CONFIG_FILE].isNullOrEmpty() + } + override fun loadSecretKey() { val keyStoreAlias = "keyStoreAlias" keyMap[keyStoreAlias] = generateMockSecretKey()