package community.yuvomi.mobile import android.content.Context import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebView import androidx.core.content.FileProvider import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import java.io.File import java.net.URI import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.HttpsURLConnection import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class UpstreamIntegrationTest { @Test fun actualUpstreamOnboardingAuthenticationLifecycleAndLogout() { val arguments = InstrumentationRegistry.getArguments() val origin = requireNotNull(arguments.getString("server")) val password = requireNotNull(arguments.getString("password")) val context = ApplicationProvider.getApplicationContext() if (android.os.Build.VERSION.SDK_INT >= 37) { InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission( context.packageName, android.Manifest.permission.ACCESS_LOCAL_NETWORK, ) } context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit() ActivityScenario.launch(MainActivity::class.java).use { scenario -> onView( withContentDescription("Yuvomi server URL"), ).perform(replaceText(origin), closeSoftKeyboard()) onView(withText("Connect securely")).perform(click()) var web: WebView? = null await("native onboarding completed") { scenario.onActivity { web = findWeb(it.window.decorView) } web != null } await("actual upstream application loaded") { documentLoaded(scenario, requireNotNull(web), origin) } // Run in the real WebView origin, preserving real Set-Cookie and CSRF. val credentials = JSONObject().put( "username", "mobiletest", ).put("password", password).toString() evaluate( scenario, requireNotNull(web), """ window.__mobileResult = null; fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify($credentials)}).then(async r => { const data = await r.json(); window.__mobileCsrf=data.csrfToken; window.__mobileResult=r.status; }); """.trimIndent(), ) await("upstream password login") { evaluate(scenario, requireNotNull(web), "window.__mobileResult") == "200" } assertEquals( "false", evaluate(scenario, requireNotNull(web), "document.cookie.includes('yuvomi.sid=')"), ) evaluate( scenario, requireNotNull(web), """ window.__mobileResult=null; fetch('/api/v1/documents',{method:'POST',headers:{'Content-Type':'application/json', 'X-CSRF-Token':window.__mobileCsrf},body:JSON.stringify({name:'Mobile upload', original_name:'mobile.txt',content_data:'data:text/plain;base64,WXV2b21pIGZpbGUgdGVzdAo='})}) .then(async r=>{const d=await r.json();window.__mobileDocument=d.data?.id;window.__mobileResult=r.status}); """.trimIndent(), ) await("real upstream file upload") { evaluate(scenario, requireNotNull(web), "window.__mobileResult") == "201" } val documentId = evaluate( scenario, requireNotNull(web), "window.__mobileDocument", ).toLong() val downloadURL = "$origin/api/v1/documents/$documentId/download" var cookie = "" scenario.onActivity { cookie = CookieManager.getInstance().getCookie(downloadURL) } val file = File(File(context.cacheDir, "captures").apply { mkdirs() }, "upstream-download.txt") val destination = FileProvider.getUriForFile( context, "${context.packageName}.files", file, ) try { FileTransfer.save( ServerAddress.parse(origin), downloadURL, cookie, context.contentResolver, destination, ) assertEquals("Yuvomi file test\n", file.readText()) } finally { file.delete() } val blobResult = evaluate( scenario, requireNotNull(web), "URL.createObjectURL(new Blob(['Yuvomi export'],{type:'text/plain'}))", ) val blobURL = JSONArray("[$blobResult]").getString(0) val exported = CountDownLatch(1) val exportWorker = Executors.newSingleThreadExecutor() var exportSuccess = false try { scenario.onActivity { BlobTransfer.save( ServerAddress.parse(origin), requireNotNull(web), blobURL, context.contentResolver, destination, exportWorker, { true }, ) { exportSuccess = it exported.countDown() } } assertTrue(exported.await(50, TimeUnit.SECONDS)) assertTrue("Blob export in hardened application WebView", exportSuccess) assertEquals("Yuvomi export", file.readText()) } finally { exportWorker.shutdownNow() file.delete() } scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED) scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED) evaluate( scenario, requireNotNull(web), "window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)", ) await("session survives backgrounding") { evaluate(scenario, requireNotNull(web), "window.__mobileResult") == "200" } scenario.recreate() await("restored WebView") { scenario.onActivity { web = findWeb(it.window.decorView) } web != null && documentLoaded(scenario, requireNotNull(web), origin) } await("persistent session after activity recreation") { evaluate( scenario, requireNotNull(web), """ if(!window.__mobileSessionProbe){window.__mobileSessionProbe=true; fetch('/api/v1/auth/me').then(async r=>{const d=await r.json(); window.__mobileCsrf=d.csrfToken;window.__mobileResult=r.status})} window.__mobileResult """.trimIndent(), ) == "200" } evaluate( scenario, requireNotNull(web), """ window.__mobileResult=null; fetch('/api/v1/auth/logout',{method:'POST',headers:{'Content-Type':'application/json', 'X-CSRF-Token':window.__mobileCsrf},body:'{}'}).then(r=>window.__mobileResult=r.status); """.trimIndent(), ) await("CSRF-protected logout") { evaluate(scenario, requireNotNull(web), "window.__mobileResult") == "200" } evaluate( scenario, requireNotNull(web), "window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)", ) await("session revoked") { evaluate(scenario, requireNotNull(web), "window.__mobileResult") == "401" } // The upstream worker may legitimately serve the cached app shell // during an outage. Use non-cacheable metadata to exercise native // main-frame network recovery without disabling that worker. evaluate( scenario, requireNotNull(web), "fetch('/__mobile_test/network/off',{method:'POST'}).then(()=>location.assign('/api/v1/version'))", ) await("native network error recovery panel") { var visible = false scenario.onActivity { visible = hasText(it.window.decorView, "Connection interrupted") } visible } val recovery = URI("$origin/__mobile_test/network/on").toURL().openConnection() as HttpsURLConnection try { recovery.requestMethod = "POST" recovery.connectTimeout = 5_000 recovery.readTimeout = 5_000 assertEquals(200, recovery.responseCode) } finally { recovery.disconnect() } onView(withText("Retry")).perform(click()) await("reconnected to real upstream metadata") { evaluate(scenario, requireNotNull(web), "document.body?.innerText.includes('app_name')") == "true" } scenario.onActivity { requireNotNull(web).loadUrl("$origin/") } await("application restored after outage") { var restored = false scenario.onActivity { restored = requireNotNull(web).url == "$origin/" } restored && documentLoaded(scenario, requireNotNull(web), origin) } scenario.onActivity { requireNotNull(web).loadUrl("$origin/api/v1/auth/oidc/start") } await("native consent before leaving Yuvomi for OIDC") { runCatching { onView(withText("Sign-in window")).check(matches(isDisplayed())) }.isSuccess } onView(withText("Sign-in window")).perform(click()) await("OIDC callback returns to main application") { var returned = false scenario.onActivity { returned = requireNotNull(web).url == "$origin/" } val signInClosed = runCatching { onView(withText("Close")).check(doesNotExist()) }.isSuccess signInClosed && returned && documentLoaded(scenario, requireNotNull(web), origin) } evaluate( scenario, requireNotNull(web), "window.__mobileSubject=null;fetch('/api/v1/auth/me').then(r=>r.json()).then(d=>window.__mobileSubject=d.user?.username)", ) await("isolated OIDC view shares the initiating server session") { evaluate(scenario, requireNotNull(web), "window.__mobileSubject") == "\"mobile-oidc\"" } var previous = "" scenario.onActivity { previous = requireNotNull(web).url.orEmpty() requireNotNull(web).loadUrl("$origin/tasks") } await("internal module navigation") { var loaded = false scenario.onActivity { loaded = requireNotNull(web).url == "$origin/tasks" && requireNotNull(web).canGoBack() } loaded && documentLoaded(scenario, requireNotNull(web), origin) } scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } await("native back restores WebView history") { var restored = false scenario.onActivity { restored = requireNotNull(web).url == previous } restored } } } private fun documentLoaded(scenario: ActivityScenario, web: WebView, origin: String): Boolean = evaluate( scenario, web, "location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'", ) == "true" private fun evaluate(scenario: ActivityScenario, web: WebView, javascript: String): String { val completed = CountDownLatch(1) var result = "" scenario.onActivity { web.evaluateJavascript(javascript) { result = it completed.countDown() } } assertTrue("JavaScript evaluation timed out", completed.await(10, TimeUnit.SECONDS)) return result } private fun await(label: String, condition: () -> Boolean) { val deadline = System.nanoTime() + 30_000_000_000L while (System.nanoTime() < deadline) { if (condition()) return Thread.sleep(100) } fail("Timed out: $label") } private fun findWeb(view: View): WebView? { if (view is WebView) return view if (view is ViewGroup) { for (i in 0 until view.childCount) { findWeb(view.getChildAt(i))?.let { return it } } } return null } private fun hasText(view: View, text: String): Boolean { if (view is android.widget.TextView && view.text.toString() == text) return true if (view is ViewGroup) { for (index in 0 until view.childCount) { if (hasText(view.getChildAt(index), text)) return true } } return false } }