8 files changed
+40484
No files matched your search
@@ -0,0 +1,93 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Vendored snapshot of the backend's published gateway contract
|
||||
* (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only.
|
||||
* Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`.
|
||||
*/
|
||||
object GatewayContract {
|
||||
private val root: JsonObject by lazy {
|
||||
val stream =
|
||||
requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) {
|
||||
"gateway-contract/openrpc.json missing from test resources"
|
||||
}
|
||||
Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject
|
||||
}
|
||||
|
||||
private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject
|
||||
|
||||
private fun names(key: String): Set<String> =
|
||||
(root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet()
|
||||
|
||||
val methods: Set<String> by lazy { names("methods") }
|
||||
|
||||
/** Server→client request methods (`x-server-requests`). */
|
||||
val serverRequests: Set<String> by lazy { names("x-server-requests") }
|
||||
|
||||
private fun paramsSchema(method: String): JsonObject {
|
||||
val entry =
|
||||
(root["methods"] as JsonArray)
|
||||
.map { it.jsonObject }
|
||||
.first { it["name"]!!.jsonPrimitive.content == method }
|
||||
val ref =
|
||||
entry["params"]!!
|
||||
.jsonArray
|
||||
.first()
|
||||
.jsonObject["schema"]!!
|
||||
.jsonObject["\$ref"]!!
|
||||
.jsonPrimitive.content
|
||||
return schemas[ref.substringAfterLast('/')]!!.jsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the backend would reject for [method]: unknown keys when the params
|
||||
* schema forbids extras, plus required keys that are absent. Empty means OK.
|
||||
* Keys only; value types are not checked.
|
||||
*/
|
||||
fun paramKeyProblems(
|
||||
method: String,
|
||||
keys: Set<String>,
|
||||
): List<String> {
|
||||
if (method !in methods) return listOf("$method is not a contract method")
|
||||
val schema = paramsSchema(method)
|
||||
val properties = schema["properties"]?.jsonObject?.keys.orEmpty()
|
||||
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()
|
||||
val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false
|
||||
val problems = mutableListOf<String>()
|
||||
if (strict) {
|
||||
(keys - properties).sorted().forEach {
|
||||
problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})"
|
||||
}
|
||||
}
|
||||
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
|
||||
return problems
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WsMethods constants the backend contract does not list. Every entry needs a
|
||||
* reason; the test fails if an entry appears in the contract or is no longer a
|
||||
* WsMethods constant, so stale exceptions get removed.
|
||||
*/
|
||||
object GatewayContractAllowlist {
|
||||
private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)"
|
||||
|
||||
val entries: Map<String, String> =
|
||||
mapOf(
|
||||
"gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method",
|
||||
"clarify.respond" to LEGACY,
|
||||
"sudo.respond" to LEGACY,
|
||||
"secret.respond" to LEGACY,
|
||||
"vault.unlock.respond" to LEGACY,
|
||||
"vault.code.respond" to LEGACY,
|
||||
"vault.save_login.respond" to LEGACY,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Sends the client-owned RPCs through the real [HermesWsClient] and checks the
|
||||
* exact frame on the wire against the vendored gateway contract (#1374).
|
||||
*/
|
||||
class GatewayContractFramesTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
|
||||
private fun captureFrame(
|
||||
method: String,
|
||||
beforeConnect: () -> Unit = {},
|
||||
trigger: () -> Unit = {},
|
||||
): JsonObject {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frame = AtomicReference<JsonObject?>(null)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (frame.get() == null && name == method) {
|
||||
frame.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
beforeConnect()
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
trigger()
|
||||
assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS))
|
||||
return requireNotNull(frame.get())
|
||||
}
|
||||
|
||||
private fun assertMatchesContract(frame: JsonObject) {
|
||||
val method = (frame["method"] as JsonPrimitive).content
|
||||
val params = frame["params"] as JsonObject
|
||||
val problems = GatewayContract.paramKeyProblems(method, params.keys)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSubmitFrameMatchesContract() {
|
||||
assertMatchesContract(
|
||||
captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventsSinceReplayFrameMatchesContract() {
|
||||
assertMatchesContract(
|
||||
captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */
|
||||
class GatewayContractTest {
|
||||
private val wsMethodConstants: Map<String, String> =
|
||||
WsMethods::class.java.declaredFields
|
||||
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
|
||||
.associate { it.name to it.get(null) as String }
|
||||
|
||||
@Test
|
||||
fun everyWsMethodIsInTheContractOrAllowlisted() {
|
||||
val unknown =
|
||||
wsMethodConstants.filter { (_, method) ->
|
||||
method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries
|
||||
}
|
||||
assertTrue(
|
||||
"WsMethods constants missing from the gateway contract (backend removed or renamed them?): " +
|
||||
unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } +
|
||||
". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.",
|
||||
unknown.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopedMethodsAreContractMethods() {
|
||||
val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods
|
||||
assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowlistEntriesHaveReasonsAndAreNotStale() {
|
||||
val sent = wsMethodConstants.values.toSet()
|
||||
GatewayContractAllowlist.entries.forEach { (method, reason) ->
|
||||
assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank())
|
||||
assertTrue(
|
||||
"Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist",
|
||||
method !in GatewayContract.methods,
|
||||
)
|
||||
assertTrue(
|
||||
"Allowlist entry '$method' is no longer a WsMethods constant; remove it",
|
||||
method in sent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handledServerRequestsExistInTheContract() {
|
||||
val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests
|
||||
assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty())
|
||||
|
||||
// Informational: unsupported requests are already answered with -32601.
|
||||
val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted()
|
||||
println("Contract server requests the app does not handle: $unhandled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paramKeyCheckRejectsTheRegressionsItExistsFor() {
|
||||
// #1118: the backend forbids extra keys, so since_seq broke replay with error 4000.
|
||||
val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq"))
|
||||
assertEquals(1, problems.count { "since_seq" in it })
|
||||
assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty())
|
||||
assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun literalParamKeysAtCallSitesMatchTheContract() {
|
||||
CALL_SITE_PARAM_KEYS.forEach { (method, keys) ->
|
||||
val problems = GatewayContract.paramKeyProblems(method, keys)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
|
||||
val APP_HANDLED_SERVER_REQUESTS =
|
||||
setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt")
|
||||
|
||||
/**
|
||||
* Union of literal param keys at production call sites (the two client-owned
|
||||
* ones, prompt.submit and session.events.since, are checked against real frames
|
||||
* in [GatewayContractFramesTest]). Update together with the call site.
|
||||
*/
|
||||
val CALL_SITE_PARAM_KEYS: Map<String, Set<String>> =
|
||||
mapOf(
|
||||
// ChatViewModel, GroupChatViewModel
|
||||
"session.create" to setOf("profile", "title", "source", "hidden"),
|
||||
// ChatViewModel.resumeSession, NotificationReplyReceiver
|
||||
"session.resume" to setOf("session_id", "omit_messages"),
|
||||
// ChatViewModel, GroupChatViewModel
|
||||
"session.interrupt" to setOf("session_id"),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user