diff --git a/app/src/androidTest/java/com/gaurav/avnc/Helpers.kt b/app/src/androidTest/java/com/gaurav/avnc/Helpers.kt index 0df8c095..94809836 100644 --- a/app/src/androidTest/java/com/gaurav/avnc/Helpers.kt +++ b/app/src/androidTest/java/com/gaurav/avnc/Helpers.kt @@ -8,23 +8,31 @@ package com.gaurav.avnc +import android.app.Activity +import android.app.Instrumentation import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.os.SystemClock import android.view.View import android.widget.ProgressBar +import androidx.core.net.toUri import androidx.preference.PreferenceManager import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.ViewAssertion import androidx.test.espresso.ViewInteraction import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.platform.app.InstrumentationRegistry import junit.framework.AssertionFailedError import org.hamcrest.core.IsNot.not import org.junit.Assert +import java.io.BufferedWriter +import java.io.File /** * Global accessors @@ -93,4 +101,21 @@ class ProgressAssertion(private val test: (Int) -> Boolean) : ViewAssertion { if (view !is ProgressBar) throw AssertionFailedError("View is not a ProgressBar") Assert.assertTrue("Progress test failed for '${view.progress}'", test(view.progress)) } -} \ No newline at end of file +} + +/** + * Helper for testing file selection by user. + * + * It creates a temporary file, populated by invoking [fileWriter] + * Then Intent response is hooked up to return that file. + * + * Note: [Intents.init] must have been called before using this function + */ +fun setupFileOpenIntent(fileWriter: BufferedWriter.() -> Unit) { + val file = File.createTempFile("avnc", "test") + file.bufferedWriter().use { it.fileWriter() } + Intents.intending(IntentMatchers.hasAction(Intent.ACTION_OPEN_DOCUMENT)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(file.toUri()))) +} + +fun setupFileOpenIntent(fileContent: String) = setupFileOpenIntent { write(fileContent) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/gaurav/avnc/ui/home/ProfileEditorTest.kt b/app/src/androidTest/java/com/gaurav/avnc/ui/home/ProfileEditorTest.kt index 699f4bba..21e5a824 100644 --- a/app/src/androidTest/java/com/gaurav/avnc/ui/home/ProfileEditorTest.kt +++ b/app/src/androidTest/java/com/gaurav/avnc/ui/home/ProfileEditorTest.kt @@ -8,8 +8,11 @@ package com.gaurav.avnc.ui.home +import androidx.core.content.edit import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ScrollToAction +import androidx.test.espresso.intent.Intents import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.rules.ActivityScenarioRule @@ -17,12 +20,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.gaurav.avnc.* import com.gaurav.avnc.model.ServerProfile import org.hamcrest.core.AllOf.allOf +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class ProfileEditorTest { +class BasicEditorTest { private val testProfile = ServerProfile(name = "foo", host = "bar") @@ -68,6 +73,70 @@ class ProfileEditorTest { hasDescendant(withText(testProfile.host))) ).checkWillBeDisplayed() } +} - //TODO add more tests +@RunWith(AndroidJUnit4::class) +class PrivateKeyTest { + + private val SAMPLE_KEY = """ + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIOlzJCBaTq2RpkLatMn+PMwsQ/ncijqK5M6y5kPns+BAoAoGCCqGSM49 + AwEHoUQDQgAE5vfo9Is5+pnk+6dC3NlXA9rKNy0X6PdifyeI2OKKTGVA4cdhEOBh + MXTgJKarp26FiFnsFuWEGOO61Q/Plmv8Wg== + -----END EC PRIVATE KEY----- + """.trimIndent() + + private val SAMPLE_ENCRYPTED_KEY = """ + -----BEGIN EC PRIVATE KEY----- + Proc-Type: 4,ENCRYPTED + DEK-Info: AES-128-CBC,D18B845F2B9D4B9FEB3472809F270AD2 + + fNNjQEFap5uf3+gaQNI+A2ait7WZDjxPO6sT6MfOTQ34/HRxm7El4FWhhYMfIiGl + 5j/A30Je2OMbOEqTpiKZKYxKxWIZ27te8HpxsQkC9xvU9rxPPsd0Vt/7na5rbx1S + Iqfy2ows7NJY3f3s/GlMY2ZmaAwk6TJ3DAJ+YK0KMrc= + -----END EC PRIVATE KEY----- + """.trimIndent() + + @Rule + @JvmField + val activityRule = ActivityScenarioRule(HomeActivity::class.java) + + @Before + fun before() { + Intents.init() + targetPrefs.edit { putBoolean("prefer_advanced_editor", true) } + onView(withContentDescription(R.string.desc_add_new_server_btn)).doClick() + onView(withId(R.id.use_ssh_tunnel)).perform(ScrollToAction()) + onView(withId(R.id.use_ssh_tunnel)).doClick() + onView(withId(R.id.key_import_btn)).perform(ScrollToAction()) + } + + @After + fun after() { + targetPrefs.edit { putBoolean("prefer_advanced_editor", false) } + Intents.release() + } + + @Test + fun importValidPK() { + setupFileOpenIntent(SAMPLE_KEY) + onView(withId(R.id.key_import_btn)).doClick() + onView(withText(R.string.msg_imported)).checkWillBeDisplayed() + onView(withId(R.id.ssh_key_password)).checkIsNotDisplayed() + } + + @Test + fun importEncryptedPK() { + setupFileOpenIntent(SAMPLE_ENCRYPTED_KEY) + onView(withId(R.id.key_import_btn)).doClick() + onView(withText(R.string.msg_imported)).checkWillBeDisplayed() + onView(withId(R.id.ssh_key_password)).checkWillBeDisplayed() + } + + @Test + fun importInvalidFile() { + setupFileOpenIntent("This is not a Private Key, right?") + onView(withId(R.id.key_import_btn)).doClick() + onView(withText(R.string.msg_invalid_key_file)).checkWillBeDisplayed() + } } \ No newline at end of file diff --git a/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditorFragment.kt b/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditorFragment.kt index b4047426..1263348a 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditorFragment.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditorFragment.kt @@ -11,6 +11,7 @@ package com.gaurav.avnc.ui.home import android.app.Dialog import android.net.Uri import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -304,20 +305,28 @@ class ProfileEditorFragment : DialogFragment() { return lifecycleScope.launch(Dispatchers.IO) { - - val text = requireContext().contentResolver.openInputStream(uri).use { it?.reader()?.readText() ?: "" } - val pem = runCatching { PEMDecoder.parsePEM(text.toCharArray()) } - val encrypted = runCatching { PEMDecoder.isPEMEncrypted(pem.getOrNull()) }.getOrNull() ?: false + var key = "" + var encrypted = false + val result = runCatching { + requireContext().contentResolver.openAssetFileDescriptor(uri, "r")!!.use { + // Valid key files are only few KBs. So if selected file is too big, + // user has accidentally selected something else. + check(it.length < 200 * 1024) { "File is too big [${it.length}]" } + key = it.createInputStream().use { s -> s.reader().use { r -> r.readText() } } + } + encrypted = PEMDecoder.isPEMEncrypted(PEMDecoder.parsePEM(key.toCharArray())) + } lifecycleScope.launchWhenCreated { - if (pem.isSuccess) { - profile.sshPrivateKey = text + result.onSuccess { + profile.sshPrivateKey = key binding.keyImportBtn.setText(R.string.title_change) binding.keyImportBtn.error = null binding.isPrivateKeyEncrypted = encrypted Snackbar.make(requireView(), R.string.msg_imported, Snackbar.LENGTH_SHORT).show() - } else { + }.onFailure { Snackbar.make(requireView(), R.string.msg_invalid_key_file, Snackbar.LENGTH_LONG).show() + Log.e("ProfileEditor", "Error importing Private Key", it) } } }