test: exercise native Android flows against pinned upstream HTTPS and OIDC

Add an ephemeral HTTPS proxy and standards-shaped RS256/S256-PKCE test IdP around the unmodified upstream checkout. Require unlocked devices and nonempty, failure-free JUnit evidence. The extended API 35 suite passes all seven cases, including native Blob/file transfer, session recreation, outage recovery, isolated OIDC and back navigation; eight evidence-verifier regressions also pass. API 37 and real-provider/device validation remain release gates.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 07:32:17 +02:00
1 parent f7b9dcd91e
commit 9ca11e4fb5
7 files changed
+691

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
.DS_Store .DS_Store
.idea/ .idea/
.gradle/ .gradle/
.kotlin/
**/build/ **/build/
local.properties local.properties
*.keystore *.keystore
@@ -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<Context>()
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<MainActivity>, web: WebView, origin: String): Boolean = evaluate(
scenario,
web,
"location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'",
) == "true"
private fun evaluate(scenario: ActivityScenario<MainActivity>, 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
}
}
+53
View File
@@ -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); });
+86
View File
@@ -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(); } };
}
+152
View File
@@ -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('''<network-security-config>
<base-config cleartextTrafficPermitted="false"><trust-anchors>
<certificates src="system"/><certificates src="@raw/integration_ca"/>
</trust-anchors></base-config></network-security-config>''')
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)
+48
View File
@@ -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("<testsuite/>"), 0)
def test_malformed_reports_fail(self):
self.assertNotEqual(self.verify("<testsuite>"), 0)
def test_failed_cases_fail(self):
self.assertNotEqual(self.verify("<testsuite><testcase><failure/></testcase></testsuite>"), 0)
def test_errored_cases_fail(self):
self.assertNotEqual(self.verify("<testsuite><testcase><error/></testcase></testsuite>"), 0)
def test_skipped_cases_fail(self):
self.assertNotEqual(self.verify("<testsuite><testcase><skipped/></testcase></testsuite>"), 0)
def test_insufficient_cases_fail(self):
self.assertNotEqual(self.verify("<testsuite><testcase/></testsuite>", minimum=2), 0)
def test_passing_cases_succeed(self):
self.assertEqual(self.verify("<testsuite><testcase/></testsuite>"), 0)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -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.")