test(#549): add slash command dispatch audit tests (#564)

- SlashCommandDispatcherTest: covers all branch routes (Interrupt /
  NewSession / RpcDispatch) incl. case-insensitivity, args, slash-only,
  empty, and unknown commands.
- SlashCommandDispatchRpcTest: asserts a non-hardcoded command is
  forwarded to COMMAND_DISPATCH with the exact {name,arg,session_id}
  shape the gateway expects, args split correctly, and that client-
  handled /stop + /new do NOT hit the backend.

Verified against the live hermes-agent gateway: commands.catalog returns
185 catalog entries but command.dispatch only handles a subset, so most
forwarded commands currently 4018. These tests lock the client contract;
the backend gap is tracked separately.

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-12 14:57:37 +03:00
1 parent 168d27dcca
commit 07764955d3
2 files changed
+280

No files matched your search

@@ -0,0 +1,217 @@
package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Issue #549 — Layer 1+2 linkage: a non-hardcoded slash command must be
* forwarded to the backend via [WsMethods.COMMAND_DISPATCH] with the EXACT
* param shape the gateway expects: { name, arg, session_id }.
*
* Verified against the live backend (hermes-agent tui_gateway/server.py
* @method("command.dispatch")): the gateway reads `params["name"]` and
* `params["arg"]`. A `{"command": ...}` shape (used by an earlier probe)
* resolves to an empty name and returns error 4018. So the mobile MUST send
* name/arg, not command. This test locks that contract.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SlashCommandDispatchRpcTest {
private val testDispatcher = StandardTestDispatcher()
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
private lateinit var app: Application
private lateinit var fakeRepo: FakeChatPersistenceRepository
private var reqCount = 0
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
reqCount = 0
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any(), any<String>()) } returns 0
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository()
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
every { HermesWsClient.connect() } answers {
mockConnectionStatus.value = ConnectionStatus.CONNECTING
}
every { HermesWsClient.disconnect() } returns Unit
every { HermesWsClient.send(any(), any(), any()) } answers {
reqCount++
val id = "req-id-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.sendMessage(any(), any(), any()) } answers {
reqCount++
val id = "req-msg-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// req-id-3 = session.create (after loadSessions + fetchCommandCatalog)
mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz")))
advanceUntilIdle()
return Pair(vm, "session-xyz")
}
@Test
fun `non-hardcoded slash command forwards name arg session_id to COMMAND_DISPATCH`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodSlot = slot<String>()
val paramsSlot = slot<Map<String, Any>>()
var captured = false
every {
HermesWsClient.send(capture(methodSlot), capture(paramsSlot), any())
} answers {
captured = true
reqCount++
val id = "req-dispatch-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// /help is NOT client-special-cased -> RpcDispatch
vm.sendMessage("/help")
advanceUntilIdle()
assertTrue("expected a COMMAND_DISPATCH send", captured)
assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured)
val params = paramsSlot.captured
assertEquals("help", params["name"])
assertEquals("", params["arg"])
assertEquals(sessionId, params["session_id"])
}
@Test
fun `slash command with args forwards arg separately`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodSlot = slot<String>()
val paramsSlot = slot<Map<String, Any>>()
every {
HermesWsClient.send(capture(methodSlot), capture(paramsSlot), any())
} answers {
reqCount++
val id = "req-dispatch-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
vm.sendMessage("/queue do the thing")
advanceUntilIdle()
assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured)
val params = paramsSlot.captured
assertEquals("queue", params["name"])
assertEquals("do the thing", params["arg"])
assertEquals(sessionId, params["session_id"])
}
@Test
fun `client-special-cased commands do NOT hit COMMAND_DISPATCH`() =
runTest {
val (vm, _) = createViewModelWithSession()
val dispatched = mutableListOf<String>()
every {
HermesWsClient.send(any(), any(), any())
} answers {
// track method via the captured value isn't possible here; re-stub below
reqCount++
val id = "req-id-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// Narrow stub to record COMMAND_DISPATCH calls
val seen = mutableListOf<String>()
every {
HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any())
} answers {
seen.add("dispatch")
reqCount++
val id = "req-id-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
vm.sendMessage("/stop")
advanceUntilIdle()
vm.sendMessage("/new")
advanceUntilIdle()
// /stop -> Interrupt, /new -> NewSession: neither should RpcDispatch.
assertTrue(
"client-handled commands must not be forwarded to the backend",
seen.isEmpty(),
)
}
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Issue #549 — Layer 1 audit: the client-side slash dispatcher must route
* exactly the commands it special-cases and forward everything else to the
* backend via RpcDispatch.
*
* These are pure-logic assertions (no Android deps) covering every branch the
* issue calls out:
* - /stop, /interrupt -> Interrupt
* - /new -> NewSession
* - /NEW (case) -> NewSession
* - "/foo bar" (args) -> RpcDispatch
* - "/" only / empty -> RpcDispatch
*/
class SlashCommandDispatcherTest {
private val dispatcher = SlashCommandDispatcher()
@Test
fun `stop routes to Interrupt`() {
assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop"))
}
@Test
fun `interrupt routes to Interrupt`() {
assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/interrupt"))
}
@Test
fun `new routes to NewSession`() {
assertEquals(SlashResult.NewSession, dispatcher.dispatch("/new"))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.
assertEquals(SlashResult.NewSession, dispatcher.dispatch("/NEW"))
assertEquals(SlashResult.NewSession, dispatcher.dispatch("/New"))
}
@Test
fun `command with args forwards to RpcDispatch`() {
// Anything not /stop, /interrupt, /new goes to the backend.
assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/foo bar"))
assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/help"))
assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/status"))
}
@Test
fun `slash-only and empty forward to RpcDispatch`() {
// "/" splits to [""], lower-cased "" is not a special case.
assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/"))
assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch(""))
}
@Test
fun `unknown slash command forwards to RpcDispatch`() {
assertEquals(SlashResult.RpcDispatch, dispatcher.dispatch("/definitely-not-real"))
}
}