feat(connectors): surface active session integrations (#1091) (#1100)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 13:41:13 +03:00
1 parent 29bc367aec
commit 52e0104cd2
23 files changed
+6579 -6

No files matched your search

@@ -0,0 +1,742 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectItem
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectorParserTest {
@Test
fun `parseListResult parses standard valid envelope`() {
val payload =
mapOf(
"available" to true,
"connectors" to
listOf(
mapOf(
"connector" to "linear",
"name" to "Linear",
"connected" to true,
"enabled" to true,
"connectionStatus" to "active",
),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertTrue(success.available)
assertEquals(1, success.connectors.size)
val item = success.connectors[0]
assertEquals("linear", item.connector)
assertEquals("Linear", item.name)
assertTrue(item.connected)
assertTrue(item.enabled)
assertEquals(ConnectorStatus.CONNECTED, item.status)
assertTrue(item.isConnected)
}
@Test
fun `parseListResult handles unavailable state`() {
val payload =
mapOf(
"available" to false,
"connectors" to emptyList<Map<String, Any>>(),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertFalse(success.available)
assertTrue(success.connectors.isEmpty())
}
@Test
fun `parseListResult handles empty connectors list`() {
val payload =
mapOf(
"available" to true,
"connectors" to emptyList<Map<String, Any>>(),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertTrue(success.available)
assertTrue(success.connectors.isEmpty())
}
@Test
fun `parseListResult deliberate boolean connected precedence over stale backend statuses`() {
// When connected is true, status is ALWAYS CONNECTED even if backend reports expired or revoked
val payloadExpired =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "c1", "connected" to true, "connectionStatus" to "expired"),
mapOf("connector" to "c2", "connected" to true, "connectionStatus" to "revoked"),
),
)
val resultExpired = ConnectorParser.parseListResult(payloadExpired) as ConnectorListResult.Success
assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[0].status)
assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[1].status)
// When connected is false, status reflects expired or revoked, and stale "active" resolves to DISCONNECTED
val payloadFalse =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "expired"),
mapOf("connector" to "c4", "connected" to false, "connectionStatus" to "revoked"),
mapOf("connector" to "c5", "connected" to false, "connectionStatus" to "active"),
mapOf("connector" to "c6", "connected" to false, "connectionStatus" to "initiated"),
mapOf("connector" to "c7", "connected" to false, "connectionStatus" to "error"),
),
)
val resultFalse = ConnectorParser.parseListResult(payloadFalse) as ConnectorListResult.Success
assertEquals(ConnectorStatus.EXPIRED, resultFalse.connectors[0].status)
assertEquals(ConnectorStatus.REVOKED, resultFalse.connectors[1].status)
assertEquals(ConnectorStatus.DISCONNECTED, resultFalse.connectors[2].status)
assertEquals(ConnectorStatus.INITIATED, resultFalse.connectors[3].status)
assertEquals(ConnectorStatus.FAILED, resultFalse.connectors[4].status)
}
@Test
fun `parseListResult supports legacy aliases`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf(
"slug" to "github",
"displayName" to "GitHub App",
"status" to "expired",
"auth_url" to "https://github.com/login/oauth/authorize",
),
mapOf(
"id" to "jira",
"title" to "Jira Cloud",
"connection_status" to "revoked",
"connectUrl" to "https://auth.atlassian.com/oauth",
),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(2, success.connectors.size)
val c1 = success.connectors[0]
assertEquals("github", c1.connector)
assertEquals("GitHub App", c1.name)
assertEquals(ConnectorStatus.EXPIRED, c1.status)
assertEquals("https://github.com/login/oauth/authorize", c1.safeConnectUrl)
val c2 = success.connectors[1]
assertEquals("jira", c2.connector)
assertEquals("Jira Cloud", c2.name)
assertEquals(ConnectorStatus.REVOKED, c2.status)
assertEquals("https://auth.atlassian.com/oauth", c2.safeConnectUrl)
}
@Test
fun `parseListResult safely handles wrong optional field types without crash`() {
val payload =
mapOf(
"available" to "true",
"connectors" to
listOf(
mapOf(
"connector" to "slack",
"name" to 12345, // Number instead of string
"connected" to "true", // String instead of boolean
"enabled" to "false", // String instead of boolean
),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
val item = success.connectors[0]
assertEquals("slack", item.connector)
assertEquals("12345", item.name)
assertTrue(item.connected)
assertFalse(item.enabled)
}
@Test
fun `parseListResult maps unknown status to UNKNOWN`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf(
"connector" to "notion",
"connected" to false,
"connectionStatus" to "pending_approval_future_state",
),
),
)
val result = ConnectorParser.parseListResult(payload) as ConnectorListResult.Success
assertEquals(ConnectorStatus.UNKNOWN, result.connectors[0].status)
}
@Test
fun `parseListResult fails on malformed required envelopes without fabricating empty success`() {
// Missing "connectors" key
val missingConnectors = mapOf("available" to true)
val r1 = ConnectorParser.parseListResult(missingConnectors)
assertTrue(r1 is ConnectorListResult.Error)
assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// "connectors" is a string instead of array
val stringConnectors = mapOf("connectors" to "invalid")
val r2 = ConnectorParser.parseListResult(stringConnectors)
assertTrue(r2 is ConnectorListResult.Error)
assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// Raw array at root instead of object
val rawList = listOf("c1", "c2")
val r3 = ConnectorParser.parseListResult(rawList)
assertTrue(r3 is ConnectorListResult.Error)
assertTrue((r3 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// Primitive string at root
val r4 = ConnectorParser.parseListResult("some random string")
assertTrue(r4 is ConnectorListResult.Error)
assertTrue((r4 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// Null root
val r5 = ConnectorParser.parseListResult(null)
assertTrue(r5 is ConnectorListResult.Error)
assertTrue((r5 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
}
@Test
fun `parseConnectResult parses standard connect initiated response`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"connector" to "linear",
"status" to "initiated",
"connect_url" to "https://linear.app/oauth/authorize?client_id=123",
"instruction" to "Arbitrary backend instruction that must not be exposed as UI text",
),
),
"summary" to
mapOf(
"total" to 1,
"active" to 0,
"initiated" to 1,
"failed" to 0,
),
)
val result = ConnectorParser.parseConnectResult(payload)
assertTrue(result is ConnectorConnectResult.Success)
val success = result as ConnectorConnectResult.Success
assertEquals(1, success.results.size)
val item = success.results[0]
assertEquals("linear", item.connector)
assertEquals(ConnectorConnectStatus.INITIATED, item.status)
assertTrue(item.isConnectUrlValid)
assertEquals("https://linear.app/oauth/authorize?client_id=123", item.safeConnectUrl)
assertEquals(1, success.summary.total)
assertEquals(1, success.summary.initiated)
assertEquals(0, success.summary.active)
}
@Test
fun `parseConnectResult parses connect already active response`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"connector" to "linear",
"status" to "active",
),
),
"summary" to
mapOf(
"total" to 1,
"active" to 1,
"initiated" to 0,
"failed" to 0,
),
)
val result = ConnectorParser.parseConnectResult(payload)
assertTrue(result is ConnectorConnectResult.Success)
val success = result as ConnectorConnectResult.Success
assertEquals(1, success.results.size)
val item = success.results[0]
assertEquals("linear", item.connector)
assertEquals(ConnectorConnectStatus.ACTIVE, item.status)
assertTrue(item.status.isActive)
assertNull(item.safeConnectUrl)
}
@Test
fun `parseConnectResult supports legacy aliases and unknown status`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"slug" to "github",
"connectionStatus" to "failed",
"auth_url" to "https://github.com/login",
),
mapOf(
"connector" to "notion",
"status" to "custom_unknown_status",
),
),
"summary" to
mapOf(
"total" to "2", // String count coerced safely
"failed" to "1",
),
)
val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success
assertEquals(2, result.results.size)
assertEquals("github", result.results[0].connector)
assertEquals(ConnectorConnectStatus.FAILED, result.results[0].status)
assertEquals("https://github.com/login", result.results[0].safeConnectUrl)
assertEquals("notion", result.results[1].connector)
assertEquals(ConnectorConnectStatus.UNKNOWN, result.results[1].status)
assertEquals(2, result.summary.total)
assertEquals(1, result.summary.failed)
assertEquals(0, result.summary.active)
}
@Test
fun `parseConnectResult rejects insecure or invalid auth URLs`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"connector" to "insecure_http",
"status" to "initiated",
"connect_url" to "http://example.com/oauth",
),
mapOf(
"connector" to "js_proto",
"status" to "initiated",
"connect_url" to "javascript:alert(1)",
),
mapOf(
"connector" to "user_creds",
"status" to "initiated",
"connect_url" to "https://user:pass@evil.com/",
),
),
"summary" to
mapOf(
"total" to 3,
"initiated" to 3,
),
)
val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success
assertEquals(3, result.results.size)
for (item in result.results) {
assertFalse(item.isConnectUrlValid)
assertNull(item.safeConnectUrl)
}
}
@Test
fun `parseConnectResult fails on malformed required envelope without fabricating success`() {
// Missing results
val r1 = ConnectorParser.parseConnectResult(mapOf("summary" to mapOf("total" to 0)))
assertTrue(r1 is ConnectorConnectResult.Error)
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
// Results is a string
val r2 = ConnectorParser.parseConnectResult(mapOf("results" to "not a list"))
assertTrue(r2 is ConnectorConnectResult.Error)
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
// Primitive string
val r3 = ConnectorParser.parseConnectResult("random text")
assertTrue(r3 is ConnectorConnectResult.Error)
}
@Test
fun `toString redacts auth URLs on ConnectorItem and ConnectorConnectItem`() {
val sensitiveUrl = "https://example.com/oauth?client_secret=super_secret_token_12345"
val item =
ConnectorItem(
connector = "linear",
name = "Linear",
connectUrl = sensitiveUrl,
)
assertFalse(item.toString().contains("super_secret_token_12345"))
assertTrue(item.toString().contains("[REDACTED]"))
val connectItem =
ConnectorConnectItem(
connector = "linear",
status = ConnectorConnectStatus.INITIATED,
connectUrl = sensitiveUrl,
)
assertFalse(connectItem.toString().contains("super_secret_token_12345"))
assertTrue(connectItem.toString().contains("[REDACTED]"))
}
@Test
fun `mapRpcError correctly maps backend codes and reasons`() {
// 4000 INVALID_PARAMS
val e4000 =
ConnectorParser.mapRpcError(
code = 4000,
message = "session_id required",
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))),
)
assertTrue(e4000 is ConnectorError.InvalidParams)
// 4001 NOT_OWNER
val e4001 =
ConnectorParser.mapRpcError(
code = 4001,
message = "session not owned",
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
)
assertTrue(e4001 is ConnectorError.NotOwner)
// 4031 CONNECTORS_UNAVAILABLE
val e4031 =
ConnectorParser.mapRpcError(
code = 4031,
message = "connectors unavailable",
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
)
assertTrue(e4031 is ConnectorError.Unavailable)
// 5033 UNSUPPORTED_RUNTIME
val e5033 =
ConnectorParser.mapRpcError(
code = 5033,
message = "compute host required",
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
)
assertTrue(e5033 is ConnectorError.UnsupportedRuntime)
// 5034 INVALID_CONNECTOR_RESPONSE
val e5034a =
ConnectorParser.mapRpcError(
code = 5034,
message = "invalid response",
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
)
assertTrue(e5034a is ConnectorError.InvalidResponse)
// 5034 CONNECTOR_REQUEST_FAILED
val e5034b =
ConnectorParser.mapRpcError(
code = 5034,
message = "request failed",
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTOR_REQUEST_FAILED"))),
)
assertTrue(e5034b is ConnectorError.RequestFailed)
// -32601 Method not found
val e32601 =
ConnectorParser.mapRpcError(
code = -32601,
message = "Method not found",
data = null,
)
assertTrue(e32601 is ConnectorError.UnsupportedBackend)
// Other code
val eOther =
ConnectorParser.mapRpcError(
code = 9999,
message = "Custom failure",
data = null,
)
assertTrue(eOther is ConnectorError.Other)
assertEquals(9999, (eOther as ConnectorError.Other).code)
}
@Test
fun `mapRpcError never leaks raw backend messages or secrets into typed error or toString`() {
val secretApiKey = "sk-live-secret-super-sensitive-api-key-987654321"
val secretDbPass = "postgres://user:super_secret_db_pass@10.0.0.1:5432/db"
// Known error code with secret in message
val e4000 =
ConnectorParser.mapRpcError(
code = 4000,
message = "Invalid auth header: $secretApiKey",
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))),
)
assertFalse("Error message must not leak secret", e4000.message.contains(secretApiKey))
assertFalse("Error toString must not leak secret", e4000.toString().contains(secretApiKey))
assertEquals("Invalid connector parameters.", e4000.message)
// Unknown error code with secret in message
val eUnknown =
ConnectorParser.mapRpcError(
code = 8888,
message = "Internal connection failed to $secretDbPass",
data = null,
)
assertFalse("Unknown error message must not leak secret", eUnknown.message.contains(secretDbPass))
assertFalse("Unknown error toString must not leak secret", eUnknown.toString().contains(secretDbPass))
assertEquals("Connector operation failed.", eUnknown.message)
}
@Test
fun `parseConnectorItem strictly rejects non-string identifiers and invalid slug formats`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf("connector" to true, "name" to "Boolean Slug"),
mapOf("id" to 12345, "name" to "Number ID"),
mapOf("slug" to "INVALID UPPERCASE", "name" to "Uppercase"),
mapOf("connector" to "-leading-hyphen", "name" to "Leading Hyphen"),
mapOf("connector" to "slug with spaces", "name" to "Spaces"),
mapOf("connector" to "valid_slug-1", "name" to "Valid Item"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(1, success.connectors.size)
assertEquals("valid_slug-1", success.connectors[0].connector)
assertEquals("Valid Item", success.connectors[0].name)
}
@Test
fun `parseListResult deduplicates items by slug to prevent duplicate keys in lazy lists`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "github", "name" to "First GitHub"),
mapOf("connector" to "github", "name" to "Duplicate GitHub"),
mapOf("connector" to "slack", "name" to "Slack"),
mapOf("connector" to "slack", "name" to "Duplicate Slack"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(2, success.connectors.size)
assertEquals("github", success.connectors[0].connector)
assertEquals("First GitHub", success.connectors[0].name)
assertEquals("slack", success.connectors[1].connector)
assertEquals("Slack", success.connectors[1].name)
}
@Test
fun `parseListResult fails with MalformedEnvelope when nonempty connectors array contains only invalid items`() {
val payload =
mapOf(
"connectors" to
listOf(
"not an object",
12345,
mapOf("connector" to true),
mapOf("slug" to "INVALID SLUG"),
mapOf("bogus" to "no identifier"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Error)
assertTrue((result as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
}
@Test
fun `parseListResult tolerates mixed junk items when valid items exist`() {
val payload =
mapOf(
"connectors" to
listOf(
"random junk string",
mapOf("connector" to "linear", "name" to "Linear"),
12345,
mapOf("connector" to true),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(1, success.connectors.size)
assertEquals("linear", success.connectors[0].connector)
}
@Test
fun `parseConnectResult fails when results is empty or all items invalid`() {
// Empty results array
val payloadEmpty =
mapOf(
"results" to emptyList<Map<String, Any>>(),
"summary" to mapOf("total" to 0),
)
val rEmpty = ConnectorParser.parseConnectResult(payloadEmpty)
assertTrue(rEmpty is ConnectorConnectResult.Error)
assertTrue((rEmpty as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
// All invalid items
val payloadInvalid =
mapOf(
"results" to
listOf(
mapOf("connector" to true),
mapOf("id" to 12345),
mapOf("connector" to "INVALID UPPER"),
),
"summary" to mapOf("total" to 3),
)
val rInvalid = ConnectorParser.parseConnectResult(payloadInvalid)
assertTrue(rInvalid is ConnectorConnectResult.Error)
assertTrue((rInvalid as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
}
@Test
fun `parseConnectResult fails when summary object is missing or non-object in standard envelope`() {
// Missing summary
val payloadMissingSummary =
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "active"),
),
)
val r1 = ConnectorParser.parseConnectResult(payloadMissingSummary)
assertTrue(r1 is ConnectorConnectResult.Error)
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
// Non-object summary (string)
val payloadStringSummary =
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "active"),
),
"summary" to "invalid_string_summary",
)
val r2 = ConnectorParser.parseConnectResult(payloadStringSummary)
assertTrue(r2 is ConnectorConnectResult.Error)
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
}
@Test
fun `parseConnectResult tolerates purposeful legacy bare array form and derives summary`() {
val legacyPayload =
listOf(
mapOf("connector" to "linear", "status" to "active"),
mapOf("connector" to "github", "status" to "initiated", "connect_url" to "https://github.com/login"),
)
val result = ConnectorParser.parseConnectResult(legacyPayload)
assertTrue(result is ConnectorConnectResult.Success)
val success = result as ConnectorConnectResult.Success
assertEquals(2, success.results.size)
assertEquals("linear", success.results[0].connector)
assertEquals(ConnectorConnectStatus.ACTIVE, success.results[0].status)
assertEquals("github", success.results[1].connector)
assertEquals(ConnectorConnectStatus.INITIATED, success.results[1].status)
// Summary should be synthesized
assertEquals(2, success.summary.total)
assertEquals(1, success.summary.active)
assertEquals(1, success.summary.initiated)
assertEquals(0, success.summary.failed)
}
@Test
fun `legacy connected consistency - status authorized yields effective connected when boolean is missing`() {
val payload =
mapOf(
"connectors" to
listOf(
// Missing "connected" boolean, status "authorized"
mapOf("connector" to "c1", "connectionStatus" to "authorized"),
// Missing "connected" boolean, status "active"
mapOf("connector" to "c2", "connectionStatus" to "active"),
// Missing "connected" boolean, status "connected"
mapOf("connector" to "c3", "connectionStatus" to "connected"),
// Missing "connected" boolean, status "expired"
mapOf("connector" to "c4", "connectionStatus" to "expired"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(4, success.connectors.size)
val c1 = success.connectors[0]
assertEquals(ConnectorStatus.CONNECTED, c1.status)
assertTrue(c1.connected)
assertTrue(c1.isConnected)
val c2 = success.connectors[1]
assertEquals(ConnectorStatus.CONNECTED, c2.status)
assertTrue(c2.connected)
assertTrue(c2.isConnected)
val c3 = success.connectors[2]
assertEquals(ConnectorStatus.CONNECTED, c3.status)
assertTrue(c3.connected)
assertTrue(c3.isConnected)
val c4 = success.connectors[3]
assertEquals(ConnectorStatus.EXPIRED, c4.status)
assertFalse(c4.connected)
assertFalse(c4.isConnected)
}
@Test
fun `explicit connected false is authoritative and not overridden by status active or authorized`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "c1", "connected" to false, "connectionStatus" to "active"),
mapOf("connector" to "c2", "connected" to false, "connectionStatus" to "authorized"),
mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "connected"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
for (item in success.connectors) {
assertFalse(item.connected)
assertFalse(item.isConnected)
assertEquals(ConnectorStatus.DISCONNECTED, item.status)
}
}
}
@@ -0,0 +1,376 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class ConnectorRepositoryTest {
@Test
fun `exact method constants match upstream gateway expectations`() {
assertEquals("connectors.list", WsMethods.CONNECTORS_LIST)
assertEquals("connectors.connect", WsMethods.CONNECTORS_CONNECT)
}
@Test
fun `listConnectors sends exact parameters without extra profile injection`() =
runBlocking {
var recordedMethod: String? = null
var recordedParams: Map<String, Any>? = null
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
},
)
val result = repo.listConnectors("sess-abc-123")
assertTrue(result is ConnectorListResult.Success)
assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod)
assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams)
// Verify no profile or singular slug mutations
assertFalse(recordedParams?.containsKey("profile") == true)
assertFalse(recordedParams?.containsKey("connector") == true)
}
@Test
fun `connect sends exact parameters with connectors array and reconnect boolean`() =
runBlocking {
var recordedMethod: String? = null
var recordedParams: Map<String, Any>? = null
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "initiated"),
),
"summary" to mapOf("total" to 1, "initiated" to 1),
)
},
)
val result =
repo.connect(
sessionId = "sess-456",
connectors = listOf("linear", "github_app-1"),
reconnect = true,
)
assertTrue(result is ConnectorConnectResult.Success)
assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod)
assertEquals("sess-456", recordedParams?.get("session_id"))
assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors"))
assertEquals(true, recordedParams?.get("reconnect"))
// Verify no singular slug key mutation
assertFalse(recordedParams?.containsKey("connector") == true)
}
@Test
fun `connect preserves reconnect false default`() =
runBlocking {
var recordedParams: Map<String, Any>? = null
val repo =
HermesConnectorRepository(
rpcRequest = { _, params ->
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "slack", "status" to "active"),
),
"summary" to mapOf("total" to 1, "active" to 1),
)
},
)
repo.connect(sessionId = "sess-789", connectors = listOf("slack"))
assertEquals(false, recordedParams?.get("reconnect"))
}
@Test
fun `listConnectors validates non-blank session_id before network`() =
runBlocking {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
)
val r1 = repo.listConnectors("")
val r2 = repo.listConnectors(" ")
assertFalse(rpcCalled)
assertTrue(r1 is ConnectorListResult.Error)
assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.InvalidParams)
assertTrue(r2 is ConnectorListResult.Error)
assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.InvalidParams)
}
@Test
fun `connect validates session_id and connectors list before network`() =
runBlocking {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
)
// Blank session_id
val r1 = repo.connect(sessionId = "", connectors = listOf("linear"))
assertFalse(rpcCalled)
assertTrue(r1 is ConnectorConnectResult.Error)
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
// Empty connectors
val r2 = repo.connect(sessionId = "sess-1", connectors = emptyList())
assertFalse(rpcCalled)
assertTrue(r2 is ConnectorConnectResult.Error)
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
}
@Test
fun `connect validates slug pattern matches backend expectations`() =
runBlocking {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
)
// Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols
val invalidSlugs =
listOf(
"Linear", // uppercase
"-linear", // leading hyphen
"_linear", // leading underscore
"linear app", // space
"linear/app", // slash
"linear@app", // symbol
"", // empty
)
for (invalidSlug in invalidSlugs) {
rpcCalled = false
val result = repo.connect(sessionId = "sess-1", connectors = listOf(invalidSlug))
assertFalse("Expected RPC not to be called for invalid slug '$invalidSlug'", rpcCalled)
assertTrue("Expected InvalidParams error for '$invalidSlug'", result is ConnectorConnectResult.Error)
assertTrue((result as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
}
// Valid slugs
assertTrue(ConnectorRepository.isValidSlug("gmail"))
assertTrue(ConnectorRepository.isValidSlug("google_drive"))
assertTrue(ConnectorRepository.isValidSlug("jira-service-desk"))
assertTrue(ConnectorRepository.isValidSlug("0auth_provider"))
}
@Test
fun `cancellation exception rethrows and is not swallowed`() =
runBlocking {
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw CancellationException("coroutine cancelled")
},
)
try {
repo.listConnectors("sess-1")
fail("Expected CancellationException to be rethrown on listConnectors")
} catch (e: CancellationException) {
assertEquals("coroutine cancelled", e.message)
}
try {
repo.connect("sess-1", listOf("linear"))
fail("Expected CancellationException to be rethrown on connect")
} catch (e: CancellationException) {
assertEquals("coroutine cancelled", e.message)
}
}
@Test
fun `maps HermesRpcException to typed errors`() =
runBlocking {
// 4031 CONNECTORS_UNAVAILABLE
val repo4031 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Connectors unavailable",
code = 4031,
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
)
},
)
val r4031 = repo4031.listConnectors("sess-1")
assertTrue(r4031 is ConnectorListResult.Error)
assertTrue((r4031 as ConnectorListResult.Error).error is ConnectorError.Unavailable)
// 4001 NOT_OWNER
val repo4001 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Session not owned",
code = 4001,
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
)
},
)
val r4001 = repo4001.connect("sess-1", listOf("linear"))
assertTrue(r4001 is ConnectorConnectResult.Error)
assertTrue((r4001 as ConnectorConnectResult.Error).error is ConnectorError.NotOwner)
// 5033 UNSUPPORTED_RUNTIME
val repo5033 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Compute host required",
code = 5033,
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
)
},
)
val r5033 = repo5033.connect("sess-1", listOf("linear"))
assertTrue(r5033 is ConnectorConnectResult.Error)
assertTrue((r5033 as ConnectorConnectResult.Error).error is ConnectorError.UnsupportedRuntime)
// 5034 INVALID_CONNECTOR_RESPONSE
val repo5034 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Invalid connector response",
code = 5034,
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
)
},
)
val r5034 = repo5034.connect("sess-1", listOf("linear"))
assertTrue(r5034 is ConnectorConnectResult.Error)
assertTrue((r5034 as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
// -32601 Unsupported backend
val repo32601 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Method not found",
code = -32601,
data = null,
)
},
)
val r32601 = repo32601.listConnectors("sess-1")
assertTrue(r32601 is ConnectorListResult.Error)
assertTrue((r32601 as ConnectorListResult.Error).error is ConnectorError.UnsupportedBackend)
}
@Test
fun `generic network exception maps to typed NetworkError`() =
runBlocking {
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw java.io.IOException("Connection reset by peer")
},
)
val rList = repo.listConnectors("sess-1")
assertTrue(rList is ConnectorListResult.Error)
assertTrue((rList as ConnectorListResult.Error).error is ConnectorError.NetworkError)
val rConnect = repo.connect("sess-1", listOf("linear"))
assertTrue(rConnect is ConnectorConnectResult.Error)
assertTrue((rConnect as ConnectorConnectResult.Error).error is ConnectorError.NetworkError)
}
@Test
fun `default rpcRequest cancels underlying deferred on caller cancellation for listConnectors`() =
runBlocking {
mockkObject(HermesWsClient)
try {
val deferred = CompletableDeferred<Any?>()
every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any(), any()) } returns deferred
every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any()) } returns deferred
val repo = HermesConnectorRepository()
val job =
launch {
repo.listConnectors("sess-cancel-test")
}
yield()
assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled)
job.cancel()
job.join()
assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled)
} finally {
unmockkObject(HermesWsClient)
}
}
@Test
fun `default rpcRequest cancels underlying deferred on caller cancellation for connect`() =
runBlocking {
mockkObject(HermesWsClient)
try {
val deferred = CompletableDeferred<Any?>()
every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any(), any()) } returns deferred
every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any()) } returns deferred
val repo = HermesConnectorRepository()
val job =
launch {
repo.connect("sess-cancel-test", listOf("linear"))
}
yield()
assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled)
job.cancel()
job.join()
assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled)
} finally {
unmockkObject(HermesWsClient)
}
}
}
@@ -0,0 +1,458 @@
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.buildFakePersistentCookieJar
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class HermesWsClientLoggingSecurityTest {
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
private val capturedInfoLogs = Collections.synchronizedList(mutableListOf<String>())
private val capturedWarnLogs = Collections.synchronizedList(mutableListOf<String>())
private val capturedErrorLogs = Collections.synchronizedList(mutableListOf<String>())
@Before
fun setUp() {
capturedDebugLogs.clear()
capturedInfoLogs.clear()
capturedWarnLogs.clear()
capturedErrorLogs.clear()
mockkStatic(Log::class)
every { Log.v(any<String>(), any<String>()) } answers {
capturedDebugLogs.add(secondArg())
0
}
every { Log.d(any<String>(), any<String>()) } answers {
capturedDebugLogs.add(secondArg())
0
}
every { Log.i(any<String>(), any<String>()) } answers {
capturedInfoLogs.add(secondArg())
0
}
every { Log.w(any<String>(), any<String>()) } answers {
capturedWarnLogs.add(secondArg())
0
}
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } answers {
capturedWarnLogs.add(secondArg())
0
}
every { Log.e(any<String>(), any<String>()) } answers {
capturedErrorLogs.add(secondArg())
0
}
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } answers {
capturedErrorLogs.add(secondArg())
0
}
mockkObject(AuthManager)
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.wsUrl() } returns "ws://127.0.0.1:9119/"
CookieManager.setJarForTest(buildFakePersistentCookieJar())
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as AtomicInteger).set(1)
HermesWsClient.rejectAllPending()
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.setAppForeground(true)
}
@After
fun tearDown() {
HermesWsClient.rejectAllPending()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
unmockkAll()
}
private fun createWsListener(generation: Int? = null): WebSocketListener {
val targetGen =
generation ?: run {
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as AtomicInteger).get()
}
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
return constructor.newInstance(targetGen) as WebSocketListener
}
private fun attachMockSocket(): WebSocket {
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
return socket
}
@Suppress("UNCHECKED_CAST")
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
field.isAccessible = true
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
}
private fun getTimeoutJob(pendingCall: Any): Job? {
val field = pendingCall.javaClass.getDeclaredField("timeoutJob")
field.isAccessible = true
return field.get(pendingCall) as? Job
}
// ── Formatter unit tests ──────────────────────────────────────────────────
@Test
fun `formatSafeOutgoingFrameLog does not leak payload parameters or urls`() {
val sensitiveUrl = "https://example.com/oauth/authorize?token=secret123"
val log =
HermesWsClient.formatSafeOutgoingFrameLog(
id = "req-42",
method = WsMethods.CONNECTORS_CONNECT,
paramsKeys = listOf("session_id", "connectors", "reconnect"),
byteLength = 256,
queued = false,
)
assertFalse("Log must not contain sensitive URL", log.contains(sensitiveUrl))
assertFalse("Log must not contain 'secret'", log.contains("secret"))
assertTrue("Log should contain id", log.contains("id=req-42"))
assertTrue("Log should contain method", log.contains("method=connectors.connect"))
assertTrue(
"Log should contain parameter keys only",
log.contains("paramsKeys=[session_id,connectors,reconnect]"),
)
assertTrue("Log should contain byte length", log.contains("bytes=256"))
}
@Test
fun `formatSafeQueuedFrameLog does not leak sensitive payload`() {
val sensitiveUrl = "https://auth.provider.com/login?key=supersecret"
val rawJson =
buildJsonObject {
put("id", "q-1")
put("method", "connectors.connect")
put(
"params",
buildJsonObject {
put("session_id", "sid-1")
put("secret_url", sensitiveUrl)
},
)
}.toString()
val log = HermesWsClient.formatSafeQueuedFrameLog(rawJson)
assertFalse("Queued frame log must not leak sensitive url", log.contains(sensitiveUrl))
assertFalse("Queued frame log must not contain 'supersecret'", log.contains("supersecret"))
assertTrue("Queued frame log should mark as queued", log.contains("→ (queued)"))
assertTrue("Queued frame log should show method", log.contains("method=connectors.connect"))
}
@Test
fun `formatSafeIncomingFrameLog does not leak connect_url or token from response`() {
val sensitiveAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=123&token=abc_secret_token"
val responseJson =
"""
{
"jsonrpc": "2.0",
"id": "req-99",
"result": {
"results": [
{
"connector": "google",
"status": "initiated",
"connect_url": "$sensitiveAuthUrl",
"instruction": "Open this link in your browser to sign in"
}
],
"summary": {"total": 1}
}
}
""".trimIndent()
val log = HermesWsClient.formatSafeIncomingFrameLog(responseJson)
assertFalse("Incoming frame log must not leak sensitive auth url", log.contains(sensitiveAuthUrl))
assertFalse("Incoming frame log must not leak secret token", log.contains("abc_secret_token"))
assertFalse("Incoming frame log must not leak instructions", log.contains("Open this link"))
assertTrue("Incoming frame log should contain id", log.contains("id=req-99"))
assertTrue("Incoming frame log should summarize result keys", log.contains("result=[results,summary]"))
}
@Test
fun `formatSafeIncomingFrameLog handles malformed or non-object json safely`() {
val log1 = HermesWsClient.formatSafeIncomingFrameLog("plain string not json")
assertTrue(log1.startsWith("← frame bytes="))
val log2 = HermesWsClient.formatSafeIncomingFrameLog("[1, 2, 3]")
assertTrue(log2.startsWith("← non-object frame bytes="))
}
// ── Real inbound WsListenerImpl.onMessage path regression tests ───────────
@Test
fun `inbound onMessage delivers result with connect_url while Log_d redacts secrets`() {
val socket = attachMockSocket()
val listener = createWsListener()
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
val pendingCalls = getPendingCalls()
val reqId = pendingCalls.keys.firstOrNull()
assertNotNull("Pending call ID must exist", reqId)
val sensitiveAuthUrl =
"https://auth.synthetic-fixture.local/oauth/authorize?code=fixture-code-99&token=fixture-secret-token-xyz"
val responseJson =
"""
{
"jsonrpc": "2.0",
"id": "$reqId",
"result": {
"status": "initiated",
"connect_url": "$sensitiveAuthUrl"
}
}
""".trimIndent()
listener.onMessage(socket, responseJson)
assertTrue("Deferred request must be completed", deferred.isCompleted)
val result = runBlocking { deferred.await() }
assertTrue("Result must be delivered as JsonObject", result is JsonObject)
val resultObj = result as JsonObject
assertEquals(
sensitiveAuthUrl,
(resultObj["connect_url"] as? JsonPrimitive)?.content,
)
val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs
for (log in allLogs) {
assertFalse("Captured log must not leak sensitive auth URL: $log", log.contains(sensitiveAuthUrl))
assertFalse("Captured log must not leak secret token: $log", log.contains("fixture-secret-token-xyz"))
assertFalse("Captured log must not leak fixture code: $log", log.contains("fixture-code-99"))
assertFalse(
"Captured log must not leak synthetic auth host: $log",
log.contains("https://auth.synthetic-fixture.local"),
)
}
assertTrue(
"Log.d must record safe sanitized incoming frame log with id and sanitized keys",
capturedDebugLogs.any {
it.contains("id=$reqId") && (
it.contains("result=[status,connect_url]") ||
it.contains("result=[connect_url,status]")
)
},
)
}
@Test
fun `inbound onMessage rpc error 4001 NOT_OWNER completes deferred exceptionally and preserves shared events`() {
val socket = attachMockSocket()
val listener = createWsListener()
val capturedEvents = Collections.synchronizedList(mutableListOf<WsEvent>())
val subscribedLatch = CountDownLatch(1)
val errorEventLatch = CountDownLatch(1)
var receivedErrorEvent: WsEvent.RpcError? = null
val collectJob =
CoroutineScope(Dispatchers.IO).launch {
HermesWsClient.events
.onSubscription { subscribedLatch.countDown() }
.collect {
capturedEvents.add(it)
if (it is WsEvent.RpcError) {
receivedErrorEvent = it
errorEventLatch.countDown()
}
}
}
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
val pendingCalls = getPendingCalls()
val reqId = pendingCalls.keys.firstOrNull()
assertNotNull("Pending call ID must exist", reqId)
val errorJson =
"""
{
"jsonrpc": "2.0",
"id": "$reqId",
"error": {
"code": 4001,
"message": "Access denied: session ownership required",
"data": {
"reason": "NOT_OWNER",
"session_id": "sess-ownership-4001"
}
}
}
""".trimIndent()
listener.onMessage(socket, errorJson)
assertTrue("Deferred must be completed", deferred.isCompleted)
val thrown = runCatching { runBlocking { deferred.await() } }.exceptionOrNull()
assertTrue(
"Deferred must complete exceptionally with HermesRpcException",
thrown is HermesWsClient.HermesRpcException,
)
val rpcException = thrown as HermesWsClient.HermesRpcException
assertEquals(4001, rpcException.code)
assertEquals("Access denied: session ownership required", rpcException.message)
assertNotNull("Exception data must be preserved", rpcException.data)
assertTrue("Exception data must be JsonObject", rpcException.data is JsonObject)
val dataObj = rpcException.data as JsonObject
assertEquals("NOT_OWNER", (dataObj["reason"] as? JsonPrimitive)?.content)
assertEquals("sess-ownership-4001", (dataObj["session_id"] as? JsonPrimitive)?.content)
assertTrue("Shared events flow must receive RpcError event", errorEventLatch.await(3, TimeUnit.SECONDS))
collectJob.cancel()
assertNotNull("Captured RpcError event must exist", receivedErrorEvent)
assertEquals(reqId, receivedErrorEvent!!.id)
assertEquals(4001, receivedErrorEvent!!.error.code)
assertEquals("Access denied: session ownership required", receivedErrorEvent!!.error.message)
val eventDataObj = receivedErrorEvent!!.error.data as? JsonObject
assertNotNull("Event error data must be JsonObject", eventDataObj)
assertEquals("NOT_OWNER", (eventDataObj!!["reason"] as? JsonPrimitive)?.content)
}
@Test
fun `inbound onMessage malformed frame logs safely without leaking payload or secrets`() {
val socket = attachMockSocket()
val listener = createWsListener()
val subscribedLatch = CountDownLatch(1)
val unknownEventLatch = CountDownLatch(1)
var receivedUnknownEvent: WsEvent.Unknown? = null
val collectJob =
CoroutineScope(Dispatchers.IO).launch {
HermesWsClient.events
.onSubscription { subscribedLatch.countDown() }
.collect {
if (it is WsEvent.Unknown) {
receivedUnknownEvent = it
unknownEventLatch.countDown()
}
}
}
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
capturedDebugLogs.clear()
capturedErrorLogs.clear()
val sensitiveSecret = "synthetic-secret-token-malformed-frame-777"
val sensitiveUrl = "https://auth.synthetic-trap.local/oauth/secret"
val malformedPayload = """{"token": "$sensitiveSecret", "url": "$sensitiveUrl", [syntax-broken-json]}"""
listener.onMessage(socket, malformedPayload)
assertTrue("Unknown event must be emitted for malformed frame", unknownEventLatch.await(3, TimeUnit.SECONDS))
collectJob.cancel()
assertNotNull(receivedUnknownEvent)
assertEquals(malformedPayload, receivedUnknownEvent!!.raw)
val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs
for (log in allLogs) {
assertFalse("Logs must not contain sensitive secret token: $log", log.contains(sensitiveSecret))
assertFalse("Logs must not contain sensitive auth URL: $log", log.contains(sensitiveUrl))
}
assertTrue(
"Log.e must record parse failure without leaking secrets",
capturedErrorLogs.any { it.startsWith("Failed to parse message:") },
)
}
@Test
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
attachMockSocket()
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
val pendingCalls = getPendingCalls()
val entry =
pendingCalls.entries.firstOrNull {
it.value.javaClass.name
.contains("PendingCall")
}
assertNotNull("Pending call must be registered in pendingCalls map", entry)
val reqId = entry!!.key
val timeoutJob = getTimeoutJob(entry.value)
assertNotNull("Timeout job must be initialized", timeoutJob)
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
deferred.cancel()
assertTrue("Deferred must be cancelled", deferred.isCancelled)
assertFalse(
"Pending call must be removed from pendingCalls map on cancellation",
pendingCalls.containsKey(reqId),
)
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
}
}
@@ -161,6 +161,26 @@ class WsProfileParamsTest {
assertSame(original, result)
}
@Test
fun `connectors_list is not profile scoped`() {
AuthManager.setActiveProfileId("meow")
val original = mapOf("session_id" to "sess-123")
val result = WsProfileParams.decorate(WsMethods.CONNECTORS_LIST, original)
assertSame(original, result)
}
@Test
fun `connectors_connect is not profile scoped`() {
AuthManager.setActiveProfileId("meow")
val original = mapOf("session_id" to "sess-123", "connectors" to listOf("linear"), "reconnect" to false)
val result = WsProfileParams.decorate(WsMethods.CONNECTORS_CONNECT, original)
assertSame(original, result)
}
@Test
fun `unknown method is not profile scoped`() {
AuthManager.setActiveProfileId("meow")