SHA256
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:
1 parent
f7b9dcd91e
commit
9ca11e4fb5
7 files changed
+691
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
**/build/
|
**/build/
|
||||||
local.properties
|
local.properties
|
||||||
*.keystore
|
*.keystore
|
||||||
|
|||||||
+333
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); });
|
||||||
@@ -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(); } };
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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.")
|
||||||
Reference in new issue
Block a user