feat(#1288): account-owned connectors management and tool policy (#1302)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 13:22:49 +03:00
1 parent 7c09810b23
commit 3aedb44bed
19 files changed
+1557 -14

No files matched your search

@@ -0,0 +1,255 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.AccountConnectorResult
import com.m57.hermescontrol.data.model.ConnectorTool
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AccountConnectorRepositoryTest {
@Before fun setup() {
mockkObject(AuthManager)
every { AuthManager.activeProfileId } returns MutableStateFlow("work")
every { AuthManager.currentDataScope() } returns null
}
@After fun teardown() = unmockkAll()
@Test fun `connect decodes operation snapshot and preserves authorization link without a session`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
operationFixture()
}
val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success
assertTrue(result.value.accountOwned)
assertEquals("op-a", result.value.opId)
assertEquals(
"https://example.com/authorize",
result.value.targets
.single()
.safeConnectUrl,
)
assertEquals(
mapOf(
"profile" to "work",
"owner" to mapOf("type" to "account"),
"connectors" to listOf("drive"),
"reconnect" to true,
),
calls.single().second,
)
assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first)
}
@Test fun `account operation respond status and wake retain profile and owner`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
operationFixture()
}
repo.operationStatus("op-a")
repo.operationRequest(
WsMethods.CONNECTION_RESPOND,
mapOf(
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
),
)
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
assertEquals(
listOf(
WsMethods.CONNECTORS_OPERATION_STATUS,
WsMethods.CONNECTION_RESPOND,
WsMethods.CONNECTORS_OPERATION_WAKE,
),
calls.map {
it.first
},
)
calls.forEach { (_, params) ->
assertEquals("work", params["profile"])
assertEquals(mapOf("type" to "account"), params["owner"])
assertEquals("op-a", params["op_id"])
assertFalse("session_id" in params)
}
}
@Test fun `catalog accounts tools and removal use native account endpoints`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
when (method) {
WsMethods.CONNECTORS_CATALOG -> {
mapOf(
"connectors" to
listOf(
mapOf(
"slug" to "drive",
"name" to "Google Drive",
"description" to "Files",
"category" to "storage",
),
),
)
}
WsMethods.CONNECTORS_ACCOUNTS -> {
mapOf(
"accounts" to
listOf(
mapOf(
"connection_id" to "conn-1",
"connector" to "drive",
"label" to "Work account",
"status" to "active",
"active" to true,
"alias" to null,
),
),
)
}
WsMethods.CONNECTORS_TOOLS -> {
mapOf(
"tools" to
listOf(
mapOf(
"slug" to "delete",
"name" to "Delete file",
"hints" to listOf("destructive"),
),
),
)
}
WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> {
mapOf(
"connection_id" to "conn-1",
"connector" to "drive",
"status" to "removed",
)
}
else -> {
error(method)
}
}
}
assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name)
assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label)
assertEquals(
"Delete file",
(repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name,
)
assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success)
assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" })
assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second)
assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second)
}
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
runTest {
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
val policy = (repo.policy() as AccountConnectorResult.Success).value
assertEquals("effective-revision", policy.revision)
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
assertEquals(listOf("mine"), policy.member?.tools?.get("drive"))
val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive"))
assertFalse(policy.inherited.single().toolEnabled("drive", destructive))
assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive"))
assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive"))
}
@Test fun `writes use observed member revision not effective revision or fresh overwritten version`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
if (method == WsMethods.CONNECTORS_POLICY_GET) {
policyFixture()
} else {
mapOf(
"revision" to "new-member-revision",
"effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"),
)
}
}
assertTrue(
repo.setDisabledTools(
"drive",
listOf("delete"),
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
) is AccountConnectorResult.Success,
)
assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first)
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"])
assertEquals(
mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")),
calls.first().second["change"],
)
}
@Test fun `malformed authorization does not look like a successful connect`() =
runTest {
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
}
private fun operationFixture() =
mapOf(
"op_id" to "op-a",
"seq" to 1,
"deadline_at" to 2000000000.0,
"settled" to false,
"targets" to
listOf(
mapOf(
"name" to "drive",
"kind" to "connector",
"action" to "authorize",
"state" to "initiated",
"connect_url" to "https://example.com/authorize",
),
),
)
private fun policyFixture() =
mapOf(
"effective" to
mapOf(
"mode" to "deny",
"revision" to "effective-revision",
"disabled_connectors" to emptyList<String>(),
),
"layers" to
listOf(
mapOf(
"kind" to "member",
"revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))),
),
mapOf(
"kind" to "org",
"revision" to "org-revision",
"body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))),
),
),
)
}
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectorToolLabelsTest {
@Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() {
val content = """{
"name":"tool_call",
"args":{"labels":["bug"]},
"labels":[{"app":"GitHub","text":"Create issue"}],
"result":{}
}"""
val view =
com.m57.hermescontrol.ui.chat
.parseToolOutput(content, "tool_call", false)
assertEquals("GitHub · Create issue", view?.serverDisplayName)
assertEquals("GitHub · Create issue", view?.title)
}
@Test fun `server connector labels title both live and restored tool calls`() {
val args =
Json.parseToJsonElement(
"""{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""",
)
val running = ToolViewBuilder.build("tool_call", args, null, running = true)
val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}"))
assertEquals("Google Drive · List files", running.title)
assertEquals(running.title, restored.title)
}
@Test fun `malformed metadata and actual labels argument keep fallback title`() {
val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""")
val view = ToolViewBuilder.build("tool_call", args, null)
assertTrue(view.title.isNotBlank())
assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title)
}
}
@@ -0,0 +1,207 @@
package com.m57.hermescontrol.ui.connectors
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.model.AccountConnectorResult
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorPolicy
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
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.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AccountConnectorsViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repo = mockk<AccountConnectorRepository>()
private val scopes = MutableStateFlow<DataScope?>(DataScope("server", "http://host", "work", 1))
private val events = MutableSharedFlow<WsEvent>()
private lateinit var vm: AccountConnectorsViewModel
private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap())
@Before fun setup() {
Dispatchers.setMain(dispatcher)
coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList())
coEvery { repo.catalog() } returns
AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage")))
coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList())
coEvery { repo.policy() } returns
AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member")))
coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot())
vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events)
}
@After fun teardown() {
vm.viewModelScope.cancel()
Dispatchers.resetMain()
}
@Test fun `no session required and connect retains the browser operation`() =
runTest(dispatcher) {
advanceUntilIdle()
assertFalse(vm.state.value.loading)
assertEquals(
"Google Drive",
vm.state.value.catalog
.single()
.name,
)
coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot())
vm.connect("drive")
vm.connect("drive")
advanceUntilIdle()
coVerify(exactly = 1) { repo.connect(listOf("drive"), false) }
assertEquals(
"https://example.com/auth",
vm.connectionState.value.operation
?.targets
?.single()
?.safeConnectUrl,
)
}
@Test fun `connect and remove errors remain visible rather than cleared by reload`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns
AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed"))
vm.connect("drive")
advanceUntilIdle()
assertEquals("authorization failed", vm.state.value.error)
coEvery { repo.remove("c1") } returns
AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed"))
vm.remove("c1")
advanceUntilIdle()
assertEquals("remove failed", vm.state.value.error)
assertFalse(vm.state.value.busy)
}
@Test fun `profile switch clears operation and rejects prior account update`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
vm.connect("drive")
advanceUntilIdle()
scopes.value = scopes.value?.copy(activeProfileId = "other")
advanceUntilIdle()
events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2)))
advanceUntilIdle()
assertNull(vm.connectionState.value.operation)
assertEquals(
"other",
vm.state.value.scope
?.activeProfileId,
)
}
@Test fun `late tool response cannot populate a new account scope`() =
runTest(dispatcher) {
advanceUntilIdle()
val pending =
CompletableDeferred<AccountConnectorResult<List<com.m57.hermescontrol.data.model.ConnectorTool>>>()
coEvery { repo.tools("drive", false) } coAnswers { pending.await() }
vm.loadTools("drive")
dispatcher.scheduler.runCurrent()
scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2)
advanceUntilIdle()
pending.complete(AccountConnectorResult.Success(emptyList()))
advanceUntilIdle()
assertTrue(
vm.state.value.tools
.isEmpty(),
)
assertTrue(
vm.state.value.toolsLoading
.isEmpty(),
)
}
@Test fun `continue answers account operation and settlement closes it`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok")
coEvery { repo.operationStatus("op-a") } returns
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
vm.connect("drive")
advanceUntilIdle()
vm.continueOperation()
advanceUntilIdle()
coVerify {
repo.operationRequest(
WsMethods.CONNECTION_RESPOND,
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
),
)
}
assertNull(vm.connectionState.value.operation)
}
@Test fun `session update never changes account operation`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
vm.connect("drive")
advanceUntilIdle()
events.emit(
WsEvent.ConnectionUpdate(
snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"),
),
)
advanceUntilIdle()
assertEquals(
1L,
vm.connectionState.value.operation
?.seq,
)
}
private fun snapshot() =
checkNotNull(
ConnectionOperationParser.parse(
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"seq" to 1,
"deadline_at" to 2000000000.0,
"settled" to false,
"targets" to
listOf(
mapOf(
"name" to "drive",
"kind" to "connector",
"action" to "authorize",
"state" to "initiated",
"connect_url" to "https://example.com/auth",
),
),
),
),
)
}