diff --git a/.gitignore b/.gitignore index a73d3a2..c55c237 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .idea/ .gradle/ +.kotlin/ **/build/ local.properties *.keystore diff --git a/android/app/src/androidTestIntegration/java/community/yuvomi/mobile/UpstreamIntegrationTest.kt b/android/app/src/androidTestIntegration/java/community/yuvomi/mobile/UpstreamIntegrationTest.kt new file mode 100644 index 0000000..e6d5afe --- /dev/null +++ b/android/app/src/androidTestIntegration/java/community/yuvomi/mobile/UpstreamIntegrationTest.kt @@ -0,0 +1,333 @@ +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 + } +} diff --git a/scripts/integration-server.mjs b/scripts/integration-server.mjs new file mode 100644 index 0000000..8b621b5 --- /dev/null +++ b/scripts/integration-server.mjs @@ -0,0 +1,53 @@ +// Test-only reverse proxy around the unmodified pinned upstream application. +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { startIdentityProvider } from './oidc-fixture.mjs'; + +const [upstream, directory] = process.argv.slice(2); +process.env.PORT = '0'; +process.env.BIND_ADDRESS = '127.0.0.1'; +process.env.DB_PATH = path.join(directory, 'fixture.db'); +process.env.SESSION_SECRET = randomBytes(48).toString('hex'); +process.env.SESSION_SECURE = 'true'; +process.env.DB_ENCRYPTION_KEY = ''; +process.env.NODE_ENV = 'test'; +process.env.LOG_LEVEL = 'warn'; +const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) }; +const idp = await startIdentityProvider(tls); +process.env.OIDC_ISSUER = idp.issuer; +process.env.OIDC_CLIENT_ID = idp.clientId; +process.env.OIDC_CLIENT_SECRET = idp.secret; +const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js'))); +if (!server.listening) await once(server, 'listening'); +let networkAvailable = true; +const proxy = https.createServer(tls, (request, response) => { + // Fault injection lives only in this loopback fixture, never in upstream/app code. + if (request.method === 'POST' && ['/__mobile_test/network/off', '/__mobile_test/network/on'].includes(request.url)) { + networkAvailable = request.url.endsWith('/on'); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{}'); + return; + } + if (!networkAvailable) { request.socket.destroy(); return; } + const backend = http.request({ + hostname: '127.0.0.1', port: server.address().port, + method: request.method, path: request.url, + headers: { ...request.headers, 'x-forwarded-proto': 'https', 'x-forwarded-for': '127.0.0.1' }, + }, incoming => { + response.writeHead(incoming.statusCode, incoming.headers); + incoming.pipe(response); + }); + backend.on('error', () => { response.writeHead(502); response.end(); }); + request.pipe(backend); +}); +proxy.listen(0, '127.0.0.1'); +await once(proxy, 'listening'); +process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`; +idp.setRedirect(process.env.OIDC_REDIRECT_URI); +fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port })); +process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); }); diff --git a/scripts/oidc-fixture.mjs b/scripts/oidc-fixture.mjs new file mode 100644 index 0000000..dbfe632 --- /dev/null +++ b/scripts/oidc-fixture.mjs @@ -0,0 +1,86 @@ +// Standards-shaped loopback test IdP, never shipped in the mobile apps. +// It validates single-use codes, exact redirect URI, Basic client auth and S256 +// PKCE, and signs real RS256 ID tokens containing the original nonce. +import https from 'node:https'; +import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto'; +import { once } from 'node:events'; + +export async function startIdentityProvider(tls) { + const clientId = 'mobile-test-client'; + const secret = randomBytes(32).toString('hex'); + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = { ...publicKey.export({ format: 'jwk' }), kid: 'fixture-key', alg: 'RS256', use: 'sig' }; + const codes = new Map(); + const access = new Set(); + const authenticatesClient = header => { + if (!/^Basic /i.test(header || '')) return false; + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); + const formDecode = value => decodeURIComponent(value.replace(/\+/g, ' ')); + try { + return separator >= 0 && formDecode(decoded.slice(0, separator)) === clientId && + formDecode(decoded.slice(separator + 1)) === secret; + } catch { return false; } + }; + let issuer; + let redirect; + const profile = { sub: 'mobile-oidc-subject', preferred_username: 'mobile-oidc', name: 'Mobile OIDC Test', + email: 'mobile-test@example.invalid', email_verified: true }; + const json = (response, status, data) => { + response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + response.end(JSON.stringify(data)); + }; + const idp = https.createServer(tls, async (request, response) => { + const url = new URL(request.url, issuer); + if (url.pathname === '/.well-known/openid-configuration') { + return json(response, 200, { issuer, authorization_endpoint: issuer + '/authorize', + token_endpoint: issuer + '/token', userinfo_endpoint: issuer + '/userinfo', jwks_uri: issuer + '/jwks', + response_types_supported: ['code'], subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic'], + code_challenge_methods_supported: ['S256'] }); + } + if (url.pathname === '/jwks') return json(response, 200, { keys: [key] }); + if (url.pathname === '/authorize') { + const parameters = url.searchParams; + if (parameters.get('client_id') !== clientId || parameters.get('redirect_uri') !== redirect || + parameters.get('code_challenge_method') !== 'S256' || !parameters.get('state') || !parameters.get('nonce')) { + return json(response, 400, { error: 'invalid_request' }); + } + const code = randomBytes(24).toString('hex'); + codes.set(code, { challenge: parameters.get('code_challenge'), nonce: parameters.get('nonce'), expires: Date.now() + 60_000 }); + const callback = new URL(redirect); + callback.searchParams.set('code', code); callback.searchParams.set('state', parameters.get('state')); + response.writeHead(302, { Location: callback.href, 'Cache-Control': 'no-store' }); response.end(); return; + } + if (url.pathname === '/token' && request.method === 'POST') { + let body = ''; + for await (const chunk of request) { body += chunk; if (body.length > 8192) return json(response, 413, {}); } + const parameters = new URLSearchParams(body); + const code = parameters.get('code'); const grant = codes.get(code); codes.delete(code); + if (!grant || grant.expires < Date.now() || parameters.get('grant_type') !== 'authorization_code' || + parameters.get('redirect_uri') !== redirect || !authenticatesClient(request.headers.authorization) || + createHash('sha256').update(parameters.get('code_verifier') || '').digest('base64url') !== grant.challenge) { + console.error('Test IdP grant rejected:', JSON.stringify({ + knownCode: !!grant, expired: !!grant && grant.expires < Date.now(), + redirectMatches: parameters.get('redirect_uri') === redirect, + clientMatches: authenticatesClient(request.headers.authorization), + pkceMatches: !!grant && createHash('sha256').update(parameters.get('code_verifier') || '').digest('base64url') === grant.challenge, + })); + return json(response, 400, { error: 'invalid_grant' }); + } + const header = Buffer.from(JSON.stringify({ alg: 'RS256', kid: key.kid, typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iss: issuer, aud: clientId, sub: profile.sub, nonce: grant.nonce, + iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60 })).toString('base64url'); + const token = randomBytes(24).toString('hex'); access.add(token); + const signature = sign('RSA-SHA256', Buffer.from(header + '.' + payload), privateKey).toString('base64url'); + return json(response, 200, { access_token: token, token_type: 'Bearer', expires_in: 60, id_token: header + '.' + payload + '.' + signature }); + } + if (url.pathname === '/userinfo' && access.has(request.headers.authorization?.replace(/^Bearer /, ''))) { + return json(response, 200, profile); + } + json(response, 404, { error: 'not_found' }); + }); + idp.listen(0, '127.0.0.1'); await once(idp, 'listening'); + issuer = 'https://127.0.0.1:' + idp.address().port; + return { issuer, clientId, secret, port: idp.address().port, setRedirect(value) { redirect = value; }, close() { idp.close(); } }; +} diff --git a/scripts/test-integration.py b/scripts/test-integration.py new file mode 100644 index 0000000..bb71a05 --- /dev/null +++ b/scripts/test-integration.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Real upstream HTTPS/reverse-proxy tests; optionally drive the Android emulator. + +Keys and CA trust exist only in a private temporary directory and the .integration +APK. Production and ordinary debug builds never include this trust configuration. +""" +import argparse +import http.cookiejar +import json +import os +from pathlib import Path +import secrets +import shutil +import ssl +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d" + + +def run(upstream, android): + revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() + assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}" + base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir()) + with tempfile.TemporaryDirectory(prefix="yuvomi-https-", dir=base_temp) as temporary: + directory = Path(temporary) + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-keyout", str(directory / "key.pem"), "-out", str(directory / "cert.pem"), + "-subj", "/CN=Yuvomi temporary test CA", + "-addext", "subjectAltName=IP:127.0.0.1,IP:10.0.2.2,DNS:localhost", + ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + with (directory / "server.log").open("w") as log: + process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary], + cwd=upstream, stdout=log, stderr=log, + env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem")}) + try: + for _ in range(200): + if (directory / "ready.json").exists(): + break + if process.poll() is not None: + raise RuntimeError((directory / "server.log").read_text()) + time.sleep(0.1) + ready = json.loads((directory / "ready.json").read_text()) + port = ready["port"] + base = f"https://127.0.0.1:{port}" + context = ssl.create_default_context(cafile=str(directory / "cert.pem")) + cookies = http.cookiejar.CookieJar() + client = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context), + urllib.request.HTTPCookieProcessor(cookies)) + + def request(route, payload=None, headers=None): + body = None if payload is None else json.dumps(payload).encode() + req = urllib.request.Request(base + route, body, headers={ + "Accept": "application/json", "Content-Type": "application/json", **(headers or {})}) + try: + with client.open(req, timeout=15) as response: + return response.status, response.read(), response.headers + except urllib.error.HTTPError as error: + return error.code, error.read(), error.headers + + # A certificate unknown to the system MUST be rejected. + try: + urllib.request.urlopen(base + "/api/v1/version", timeout=5) + raise AssertionError("Untrusted certificate was accepted") + except urllib.error.URLError as error: + assert isinstance(error.reason, ssl.SSLCertVerificationError) + print("PASS: untrusted TLS certificate rejected") + status, body, _ = request("/api/v1/version") + metadata = json.loads(body) + assert status == 200 and isinstance(metadata["app_name"], str) + assert metadata["setup_required"] is True and "version" not in metadata + assert isinstance(metadata["password_reset_enabled"], bool) + status, body, _ = request("/api/v1/auth/oidc/config") + assert status == 200 and json.loads(body)["enabled"] is True + print("PASS: actual upstream compatibility metadata over HTTPS reverse proxy") + password = secrets.token_urlsafe(24) + assert request("/api/v1/auth/setup", {"username": "mobiletest", "display_name": "Mobile Test", "password": password})[0] == 201 + status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password}) + assert status == 200 + session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0] + assert session.secure and session.has_nonstandard_attr("HttpOnly") + assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie")) + csrf = json.loads(body)["csrfToken"] + assert request("/api/v1/auth/me")[0] == 200 + assert json.loads(request("/api/v1/version")[1])["version"] == "2.78.0" + assert request("/api/v1/auth/logout", {})[0] == 403 + assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200 + assert request("/api/v1/auth/me")[0] == 401 + print("PASS: login, Secure/HttpOnly/SameSite cookies, persistence, CSRF rejection and logout/revocation") + assert request("/api/v1/auth/oidc/start")[0] == 200 + status, body, _ = request("/api/v1/auth/me") + identity = json.loads(body) + assert status == 200 and identity.get("user", {}).get("username") == "mobile-oidc", ( + f"OIDC session rejected (HTTP {status}); " + "\n".join( + (directory / "server.log").read_text().splitlines()[-20:])) + assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": identity["csrfToken"]})[0] == 200 + print("PASS: upstream OIDC discovery, RS256/nonce validation, S256 PKCE exchange and test-IdP session") + if android: + resources = directory / "res" + (resources / "xml").mkdir(parents=True) + (resources / "raw").mkdir() + shutil.copyfile(directory / "cert.pem", resources / "raw/integration_ca.pem") + (resources / "xml/network_security_config.xml").write_text(''' + + +''') + env = {**os.environ, "YUVOMI_TEST_RES": str(resources), + "YUVOMI_TEST_URL": base, "YUVOMI_TEST_PASSWORD": password} + sdk = Path(env.get("ANDROID_HOME", env.get("ANDROID_SDK_ROOT", ""))) + adb = sdk / "platform-tools/adb" + user_state = subprocess.check_output( + [str(adb), "shell", "am", "get-started-user-state", "0"], text=True).strip() + if user_state != "RUNNING_UNLOCKED": + raise RuntimeError(f"Unlock the emulator before testing (user state: {user_state}).") + subprocess.run([str(adb), "reverse", f"tcp:{port}", f"tcp:{port}"], check=True) + subprocess.run([str(adb), "reverse", f"tcp:{ready['idpPort']}", f"tcp:{ready['idpPort']}"], check=True) + try: + # The ephemeral password is passed through the local test runner only, + # never written into a source file or printed by this harness. + result = subprocess.run(["bash", str(ROOT / "android/gradlew"), "-p", str(ROOT / "android"), + "-Pintegration", "connectedIntegrationAndroidTest"], env=env, check=False) + if result.returncode: + raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.") + output = Path(env.get("YUVOMI_BUILD_DIR", ROOT / "android")) + reports = output / "app/outputs/androidTest-results/connected/integration" + if "YUVOMI_BUILD_DIR" not in env: + reports = ROOT / "android/app/build/outputs/androidTest-results/connected/integration" + subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"), + str(reports), "--minimum", "7"], check=True) + finally: + subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False) + subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False) + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("upstream", type=Path) + parser.add_argument("--android", action="store_true") + arguments = parser.parse_args() + run(arguments.upstream.resolve(), arguments.android) diff --git a/scripts/test-verifier.py b/scripts/test-verifier.py new file mode 100644 index 0000000..1778d7c --- /dev/null +++ b/scripts/test-verifier.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Regression tests for fail-closed JUnit evidence verification.""" +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +VERIFIER = Path(__file__).with_name("verify-test-results.py") + + +class EvidenceVerifierTests(unittest.TestCase): + def verify(self, xml=None, minimum=1): + with tempfile.TemporaryDirectory(prefix="yuvomi-evidence-") as temporary: + if xml is not None: + (Path(temporary) / "TEST-fixture.xml").write_text(xml) + return subprocess.run( + [sys.executable, str(VERIFIER), temporary, "--minimum", str(minimum)], + capture_output=True, text=True, + ).returncode + + def test_missing_reports_fail(self): + self.assertNotEqual(self.verify(), 0) + + def test_empty_reports_fail(self): + self.assertNotEqual(self.verify(""), 0) + + def test_malformed_reports_fail(self): + self.assertNotEqual(self.verify(""), 0) + + def test_failed_cases_fail(self): + self.assertNotEqual(self.verify(""), 0) + + def test_errored_cases_fail(self): + self.assertNotEqual(self.verify(""), 0) + + def test_skipped_cases_fail(self): + self.assertNotEqual(self.verify(""), 0) + + def test_insufficient_cases_fail(self): + self.assertNotEqual(self.verify("", minimum=2), 0) + + def test_passing_cases_succeed(self): + self.assertEqual(self.verify(""), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-test-results.py b/scripts/verify-test-results.py new file mode 100644 index 0000000..52330b0 --- /dev/null +++ b/scripts/verify-test-results.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Require real, nonempty JUnit results; a build tool's zero exit is insufficient.""" +import argparse +from pathlib import Path +import xml.etree.ElementTree as ET + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("directory", type=Path) +parser.add_argument("--minimum", type=int, required=True) +arguments = parser.parse_args() +cases = [] +for path in arguments.directory.rglob("TEST*.xml"): + cases.extend(ET.parse(path).getroot().iter("testcase")) +failed = sum(case.find("failure") is not None or case.find("error") is not None for case in cases) +skipped = sum(case.find("skipped") is not None for case in cases) +print(f"JUnit evidence: {len(cases)} tests, {failed} failures/errors, {skipped} skipped") +if len(cases) < arguments.minimum or failed or skipped: + raise SystemExit("Incomplete or failing test evidence; do not report this run as passing.")