This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
+207
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user